diff --git a/cmd/ledgerctl/accounts/aggregate.go b/cmd/ledgerctl/accounts/aggregate.go index 6e5c3c4724..a0577f979b 100644 --- a/cmd/ledgerctl/accounts/aggregate.go +++ b/cmd/ledgerctl/accounts/aggregate.go @@ -100,6 +100,7 @@ func runAggregateVolumes(cmd *cobra.Command, _ []string) error { { type jsonVolume struct { Asset string `json:"asset"` + Color string `json:"color"` Input string `json:"input"` Output string `json:"output"` Balance string `json:"balance"` @@ -113,6 +114,7 @@ func runAggregateVolumes(cmd *cobra.Command, _ []string) error { balance := new(big.Int).Sub(input, output) volumes = append(volumes, jsonVolume{ Asset: vol.GetAsset(), + Color: vol.GetColor(), Input: input.String(), Output: output.String(), Balance: balance.String(), @@ -131,7 +133,7 @@ func runAggregateVolumes(cmd *cobra.Command, _ []string) error { } tableData := pterm.TableData{ - {"ASSET", "INPUT", "OUTPUT", "BALANCE"}, + {"ASSET", "COLOR", "INPUT", "OUTPUT", "BALANCE"}, } for _, vol := range result.GetVolumes() { @@ -140,6 +142,7 @@ func runAggregateVolumes(cmd *cobra.Command, _ []string) error { balance := new(big.Int).Sub(input, output) tableData = append(tableData, []string{ vol.GetAsset(), + vol.GetColor(), input.String(), output.String(), formatBalance(balance), diff --git a/cmd/ledgerctl/accounts/get.go b/cmd/ledgerctl/accounts/get.go index fbe03b9a60..8917c1a5a9 100644 --- a/cmd/ledgerctl/accounts/get.go +++ b/cmd/ledgerctl/accounts/get.go @@ -3,7 +3,6 @@ package accounts import ( "errors" "fmt" - "sort" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -129,18 +128,13 @@ func runGet(cmd *cobra.Command, args []string) error { if len(account.GetVolumes()) > 0 { volumesTable := pterm.TableData{ - {"ASSET", "INPUT", "OUTPUT", "BALANCE"}, + {"ASSET", "COLOR", "INPUT", "OUTPUT", "BALANCE"}, } - assets := make([]string, 0, len(account.GetVolumes())) - for asset := range account.GetVolumes() { - assets = append(assets, asset) - } - - sort.Strings(assets) - - for _, asset := range assets { - vol := account.GetVolumes()[asset] + // account.GetVolumes() is already sorted by (asset, color) ascending + // server-side, so we just render in-order. + for _, entry := range account.GetVolumes() { + vol := entry.GetVolumes() balance := vol.GetBalance() balanceColor := pterm.Green @@ -148,8 +142,14 @@ func runGet(cmd *cobra.Command, args []string) error { balanceColor = pterm.Red } + displayColor := entry.GetColor() + if displayColor == "" { + displayColor = "-" + } + volumesTable = append(volumesTable, []string{ - asset, + entry.GetAsset(), + displayColor, vol.GetInput(), vol.GetOutput(), balanceColor(balance), diff --git a/cmd/ledgerctl/queries/execute.go b/cmd/ledgerctl/queries/execute.go index 761d6945a1..9f25deb343 100644 --- a/cmd/ledgerctl/queries/execute.go +++ b/cmd/ledgerctl/queries/execute.go @@ -233,10 +233,11 @@ func renderAggregate(cmd *cobra.Command, result *commonpb.AggregateResult) error } if len(result.GetVolumes()) > 0 { - tableData := pterm.TableData{{"ASSET", "INPUT", "OUTPUT"}} + tableData := pterm.TableData{{"ASSET", "COLOR", "INPUT", "OUTPUT"}} for _, v := range result.GetVolumes() { tableData = append(tableData, []string{ v.GetAsset(), + v.GetColor(), v.GetInput().ToBigInt().String(), v.GetOutput().ToBigInt().String(), }) @@ -249,10 +250,11 @@ func renderAggregate(cmd *cobra.Command, result *commonpb.AggregateResult) error pterm.Println() pterm.Printfln("Group: %s", g.GetPrefix()) - tableData := pterm.TableData{{"ASSET", "INPUT", "OUTPUT"}} + tableData := pterm.TableData{{"ASSET", "COLOR", "INPUT", "OUTPUT"}} for _, v := range g.GetVolumes() { tableData = append(tableData, []string{ v.GetAsset(), + v.GetColor(), v.GetInput().ToBigInt().String(), v.GetOutput().ToBigInt().String(), }) diff --git a/cmd/ledgerctl/transactions/create.go b/cmd/ledgerctl/transactions/create.go index 74039c7f58..df8ff9d122 100644 --- a/cmd/ledgerctl/transactions/create.go +++ b/cmd/ledgerctl/transactions/create.go @@ -75,7 +75,7 @@ func NewCreateCommand() *cobra.Command { Long: `Create a new transaction via gRPC. Postings can be provided via flag, or use a Numscript file. -Flag format: --posting "source,destination,amount,asset" +Flag format: --posting "source,destination,amount,asset[,color]" Examples: ledgerctl transactions create --ledger my-ledger --posting "world,bank,1000,USD" @@ -88,7 +88,7 @@ Examples: } cmd.Flags().String("ledger", "", "Name of the ledger") - cmd.Flags().StringArray("posting", nil, "Posting in format: source,destination,amount,asset (can be repeated)") + cmd.Flags().StringArray("posting", nil, "Posting in format: source,destination,amount,asset[,color] (can be repeated)") cmd.Flags().String("script", "", "Path to a Numscript file (mutually exclusive with --posting)") cmd.Flags().StringArray("var", nil, "Script variable in format: name=value (can be repeated, only with --script)") cmd.Flags().String("reference", "", "Transaction reference") @@ -460,10 +460,14 @@ func runCreate(cmd *cobra.Command, _ []string) error { pterm.Println("Postings:") postingsTable := pterm.TableData{ - {"#", "SOURCE", "", "DESTINATION", "AMOUNT", "ASSET"}, + {"#", "SOURCE", "", "DESTINATION", "AMOUNT", "ASSET", "COLOR"}, } for i, posting := range tx.GetPostings() { + color := posting.GetColor() + if color == "" { + color = "-" + } postingsTable = append(postingsTable, []string{ strconv.Itoa(i + 1), posting.GetSource(), @@ -471,6 +475,7 @@ func runCreate(cmd *cobra.Command, _ []string) error { posting.GetDestination(), posting.GetAmount().Dec(), posting.GetAsset(), + color, }) } @@ -512,20 +517,28 @@ func runCreate(cmd *cobra.Command, _ []string) error { return nil } -// parsePosting parses a posting from string format "source,destination,amount,asset". +// parsePosting parses a posting from string format +// - "source,destination,amount,asset" (uncolored, 4 fields) +// - "source,destination,amount,asset,color" (colored, 5 fields) +// +// The color field is optional. An empty fifth field is treated as no color. func parsePosting(s string) (*commonpb.Posting, error) { parts := strings.Split(s, ",") - if len(parts) != 4 { - return nil, errors.New("expected format: source,destination,amount,asset") + if len(parts) != 4 && len(parts) != 5 { + return nil, errors.New("expected format: source,destination,amount,asset[,color]") } source := strings.TrimSpace(parts[0]) destination := strings.TrimSpace(parts[1]) amountStr := strings.TrimSpace(parts[2]) asset := strings.TrimSpace(parts[3]) + color := "" + if len(parts) == 5 { + color = strings.TrimSpace(parts[4]) + } if source == "" || destination == "" || amountStr == "" || asset == "" { - return nil, errors.New("all fields are required") + return nil, errors.New("source, destination, amount and asset are required") } amount, ok := new(big.Int).SetString(amountStr, 10) @@ -533,7 +546,7 @@ func parsePosting(s string) (*commonpb.Posting, error) { return nil, fmt.Errorf("invalid amount: %s", amountStr) } - return commonpb.NewPosting(source, destination, asset, amount), nil + return commonpb.NewColoredPosting(source, destination, asset, color, amount), nil } // promptVariable prompts the user for a Numscript variable value based on its type. diff --git a/cmd/ledgerctl/transactions/display_volumes.go b/cmd/ledgerctl/transactions/display_volumes.go index dfa0e19d0e..6762e23ef1 100644 --- a/cmd/ledgerctl/transactions/display_volumes.go +++ b/cmd/ledgerctl/transactions/display_volumes.go @@ -9,6 +9,8 @@ import ( ) // renderPostCommitVolumes displays a PostCommitVolumes table in the CLI output. +// Volumes are listed per (account, asset, color). The "" color is rendered as +// "-" so the uncolored bucket stands out in the table. func renderPostCommitVolumes(pcv *commonpb.PostCommitVolumes) error { if len(pcv.GetVolumesByAccount()) == 0 { return nil @@ -18,33 +20,28 @@ func renderPostCommitVolumes(pcv *commonpb.PostCommitVolumes) error { pterm.Println("Post-Commit Volumes:") table := pterm.TableData{ - {"ACCOUNT", "ASSET", "INPUT", "OUTPUT"}, + {"ACCOUNT", "ASSET", "COLOR", "INPUT", "OUTPUT"}, } - // Sort accounts for stable output accounts := make([]string, 0, len(pcv.GetVolumesByAccount())) for account := range pcv.GetVolumesByAccount() { accounts = append(accounts, account) } - sort.Strings(accounts) for _, account := range accounts { vba := pcv.GetVolumesByAccount()[account] - - // Sort assets for stable output - assets := make([]string, 0, len(vba.GetVolumes())) - for asset := range vba.GetVolumes() { - assets = append(assets, asset) - } - - sort.Strings(assets) - - for _, asset := range assets { - v := vba.GetVolumes()[asset] + // VolumesByAssets.Volumes is sorted by (asset, color) server-side. + for _, entry := range vba.GetVolumes() { + v := entry.GetVolumes() + displayColor := entry.GetColor() + if displayColor == "" { + displayColor = "-" + } table = append(table, []string{ account, - asset, + entry.GetAsset(), + displayColor, v.GetInput(), v.GetOutput(), }) diff --git a/cmd/ledgerctl/transactions/get.go b/cmd/ledgerctl/transactions/get.go index cd2e69d913..eac0a6e864 100644 --- a/cmd/ledgerctl/transactions/get.go +++ b/cmd/ledgerctl/transactions/get.go @@ -148,7 +148,7 @@ func runGet(cmd *cobra.Command, args []string) error { pterm.Println("Postings:") postingsTable := pterm.TableData{ - {"#", "SOURCE", "", "DESTINATION", "AMOUNT", "ASSET"}, + {"#", "SOURCE", "", "DESTINATION", "AMOUNT", "ASSET", "COLOR"}, } termWidth := pterm.GetTerminalWidth() @@ -167,7 +167,7 @@ func runGet(cmd *cobra.Command, args []string) error { for line := range maxLines { src, dst := "", "" - num, arrow, amount, asset := "", "", "", "" + num, arrow, amount, asset, color := "", "", "", "", "" if line < len(srcLines) { src = srcLines[line] @@ -188,10 +188,14 @@ func runGet(cmd *cobra.Command, args []string) error { arrow = "→" amount = posting.GetAmount().Dec() asset = posting.GetAsset() + color = posting.GetColor() + if color == "" { + color = "-" + } } postingsTable = append(postingsTable, []string{ - num, src, arrow, dst, amount, asset, + num, src, arrow, dst, amount, asset, color, }) } } diff --git a/cmd/ledgerctl/transactions/revert.go b/cmd/ledgerctl/transactions/revert.go index e20c183d43..f76e2d4c8e 100644 --- a/cmd/ledgerctl/transactions/revert.go +++ b/cmd/ledgerctl/transactions/revert.go @@ -233,10 +233,14 @@ func runRevert(cmd *cobra.Command, args []string) error { pterm.Println("Revert Postings:") postingsTable := pterm.TableData{ - {"#", "SOURCE", "", "DESTINATION", "AMOUNT", "ASSET"}, + {"#", "SOURCE", "", "DESTINATION", "AMOUNT", "ASSET", "COLOR"}, } for i, posting := range revertedTx.GetRevertTransaction().GetPostings() { + color := posting.GetColor() + if color == "" { + color = "-" + } postingsTable = append(postingsTable, []string{ strconv.Itoa(i + 1), posting.GetSource(), @@ -244,6 +248,7 @@ func runRevert(cmd *cobra.Command, args []string) error { posting.GetDestination(), posting.GetAmount().Dec(), posting.GetAsset(), + color, }) } diff --git a/docs/ops/cli.md b/docs/ops/cli.md index 718a522e47..ba40ca205e 100644 --- a/docs/ops/cli.md +++ b/docs/ops/cli.md @@ -1361,7 +1361,7 @@ ledgerctl transactions create [flags] | Flag | Default | Description | |------|---------|-------------| | `--ledger` | | Name of the ledger | -| `--posting` | | Posting in format: `source,destination,amount,asset` (can be repeated) | +| `--posting` | | Posting in format: `source,destination,amount,asset[,color]` (can be repeated) | | `--script` | | Path to a Numscript file (mutually exclusive with `--posting`) | | `--var` | | Script variable in format: `name=value` (can be repeated, only with `--script`) | | `--reference` | | Transaction reference | @@ -1392,8 +1392,26 @@ ledgerctl transactions create --ledger my-ledger \ ledgerctl transactions create --ledger my-ledger \ --posting "empty-account,destination,1000,USD" \ --force + +# Colored postings (segregated balances per (account, asset, color)). +# Color must match ^[A-Z]*$. An empty/missing color is the uncolored bucket +# and is itself segregated from every colored bucket. +ledgerctl transactions create --ledger my-ledger \ + --posting "world,treasury,1000,USD/2,GRANTS" + +# Mixed colored and uncolored postings in a single transaction +ledgerctl transactions create --ledger my-ledger \ + --posting "world,treasury,500,USD/2" \ + --posting "world,treasury,500,USD/2,OPS" ``` +**Inspecting colored balances:** + +`ledgerctl accounts get` lists one row per `(asset, color)` tuple. To sum +every colored bucket of the same asset into a single uncolored entry, pass +`?collapseColors=true` on the HTTP endpoint (the CLI surfaces the raw, +segregated view by design). + **Creating transactions with Numscript:** ```bash diff --git a/docs/technical/architecture/README.md b/docs/technical/architecture/README.md index ba6a8e41da..3bb0f671c7 100644 --- a/docs/technical/architecture/README.md +++ b/docs/technical/architecture/README.md @@ -36,6 +36,7 @@ Cross-cutting concerns (overall design, data flows shared across subsystems, the |----------|-----| | [data-flows.md](data-flows.md) | Sequence diagrams that span multiple subsystems. | | [data-model.md](data-model.md) | Ledgers, transactions, postings — the core domain shape. | +| [data-model/color-of-money.md](data-model/color-of-money.md) | Per-bucket "color of money" balance segregation — key encoding, numscript contract, collapse modes. | | [audit-vs-technical-state.md](audit-vs-technical-state.md) | Boundary between business truth, governance truth, operational state, and rebuildable projections. | | [primitives/uint256-wire-format.md](primitives/uint256-wire-format.md) | Fixed-size monetary-amount wire format used everywhere on the boundary. | diff --git a/docs/technical/architecture/data-model/color-of-money.md b/docs/technical/architecture/data-model/color-of-money.md new file mode 100644 index 0000000000..dd87cc3d38 --- /dev/null +++ b/docs/technical/architecture/data-model/color-of-money.md @@ -0,0 +1,149 @@ +# Color of money + +Color is a per-posting segregation key. The ledger tracks balances per +`(account, asset, color)` triple. Two postings on the same `(account, asset)` +but different colors operate on strictly isolated balances. + +## Invariants + +1. **Color is immutable.** Once funds are emitted under a color, no operation + ever changes that color. To "convert" color X to color Y the operator must + compose two transactions: send `X` to a clearing account, then mint `Y` + from `@world` back to the original holder. + +2. **Empty color is a bucket.** `Color == ""` is the *uncolored* bucket and + participates in segregation just like any colored bucket. A posting with + `Color == ""` cannot pull funds from a colored bucket, and vice versa. + +3. **Color charset is `^[A-Z]*$`.** Enforced both by the upstream + numscript interpreter and at the ledger admission boundary. The empty + string remains valid (the uncolored bucket). + +4. **Double-entry holds per bucket.** For every `(asset, color)`, the sum of + all account balances is zero. Each bucket is its own conservation universe. + +## Wire shapes + +- `commonpb.Posting.color: string` — primary carrier of the field; rides on + every read and write path. +- `commonpb.AccountVolume{asset, color, volumes}` — `Account.volumes` is a + list sorted by `(asset, color)` ascending. Deterministic serialization is + required for stable JSON / snapshot tests. +- `commonpb.VolumeEntry{asset, color, volumes}` — same shape, used inside + `PostCommitVolumes.volumes_by_account[account].volumes`. +- `commonpb.AggregatedVolume.color: string` — set on every entry returned by + `AggregateVolumes`. By default one entry per `(asset, color)` tuple; + `collapse_colors` flag sums across colors. + +## Storage layout + +`domain.VolumeKey` carries `Color string`. Canonical bytes: + +``` +[ledgerName padded 64B] [account] \x00 [color] \x00 [asset_base] [precision 1B] +``` + +The ledger prefix is the ledger name written as a fixed-size, zero-padded +64-byte block (`dal.LedgerNameFixedSize`) by `appendLedgerName`/`readLedgerName` +in `internal/domain/keys.go` — not a numeric ledger ID. + +The color sits between account and asset so prefix scans behave naturally: + +- `[ledgerName][account]\x00` returns every `(color, asset)` for the account. +- `[ledgerName][account]\x00[color]\x00` returns every asset for that color. +- The trailing `precision` byte can be `0x00` (e.g. `"EUR"`) without + encoding ambiguity because nothing follows it. + +## Numscript contract + +The numscript interpreter (`github.com/formancehq/numscript`, pinned in +`go.mod`) exposes color as a first-class property of the output `Posting` and +carries it per row on the balance query/response contract. The user-facing +shapes are row-based (not nested maps) — color is a column on each query item +and each returned row: + +```go +// BalanceQueryItem is one (account, asset, color) the script needs; a +// BalanceQuery is the flat batch the store must answer. +type BalanceQueryItem struct { + Account string + Asset string + Color string +} +type BalanceQuery []BalanceQueryItem + +// BalanceRow is one materialized (account, asset, color) balance; Balances is +// the flat result set. Color is omitted from JSON when empty (uncolored). +type BalanceRow struct { + Account string `json:"account"` + Asset string `json:"asset"` + Amount *big.Int `json:"amount"` + Color string `json:"color,omitempty"` +} +type Balances []BalanceRow + +// Posting carries the resolved color of the moved funds. +type Posting struct { + Source string `json:"source"` + Destination string `json:"destination"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Color string `json:"color,omitempty"` +} +``` + +The Numscript syntax `source = @alice \ "GRANTS"` produces a posting with +`Color = "GRANTS"` and only draws from the matching bucket. The previous +`USD_GRANTS` asset-suffix encoding is gone — color is never folded into the +asset string anywhere in the contract. + +### Insufficient-funds color is unresolved on the Numscript path + +When a colored Numscript spend runs short, the interpreter raises +`numscriptlib.MissingFundsErr`, which carries only `{Asset, Needed, Available, +parser.Range}` — it does **not** expose the failing account or the color of the +bucket that ran short. A single asset can be sourced from several +`(account, color)` buckets in one script, and `Range` points into the source +text, not a resolved bucket, so the color cannot be recovered from the error +alone. `convertNumscriptError` therefore builds `ErrInsufficientFunds` with an +empty, *unresolved* color. + +Because `Color == ""` is a first-class bucket everywhere else, the error carries +a `ColorKnown` flag to keep the two meanings apart on the wire: + +- **Direct-posting failures** resolve the exact source bucket (`ColorKnown = + true`). An empty color there is the genuine uncolored bucket, and the `color` + key is always present in the error metadata (as `""`). +- **Numscript failures** cannot resolve the color (`ColorKnown = false`). The + `color` key is **omitted** from the error metadata entirely, so a client reads + its absence as "unknown" rather than mistaking `color: ""` for a definite hit + on the uncolored bucket. + +When a future numscript bump attaches the resolved `(account, color)` to +`MissingFundsErr`, the conversion path sets the real color with `ColorKnown = +true` and this asymmetry disappears. + +## Collapse modes + +For consumers that want a per-asset summary (totals ignoring color), the +read API exposes opt-in collapse flags: + +- `GET /{ledger}/accounts/{address}?collapseColors=true` — `Account.volumes` + returns one entry per asset with `color = ""` and amounts summed. +- `GET /{ledger}/volumes?collapseColors=true` — `AggregatedVolume` entries + collapse to one per `(asset, precision)` with `color = ""`. + +Collapse is always opt-in: the default keeps the segregation visible so +clients cannot accidentally aggregate across buckets that should stay +isolated. + +## What this does NOT do + +- **No color filtering on list queries.** This iteration does not surface a + native `WHERE color = "GRANTS"` filter on `ListAccounts` / `ListTransactions`. + Filter client-side, or rely on the generic `filterexpr` engine. +- **No re-coloring primitive.** Numscript does not (yet) expose a syntax to + mutate the color of funds in-place. The composition pattern (clearing + account + mint from `@world`) is the supported path. +- **No automatic propagation from account metadata to color.** Color is + carried by the posting, not derived from the account's metadata. diff --git a/docs/technical/architecture/subsystems/storage/storage-drivers.md b/docs/technical/architecture/subsystems/storage/storage-drivers.md index 776a0fd070..4358a76c92 100644 --- a/docs/technical/architecture/subsystems/storage/storage-drivers.md +++ b/docs/technical/architecture/subsystems/storage/storage-drivers.md @@ -79,19 +79,19 @@ Each attribute type has a single entry per canonical key (last-write-wins). | Sub-prefix | Attribute | Canonical Key Format | |------------|-----------|---------------------| -| `0x01` | Volumes | `[ledgerID BE 4B][account]\x00[asset_base][precision]` | -| `0x02` | Account Metadata | `[ledgerID BE 4B][account]\x01[key]` | -| `0x03` | Transaction State | `[ledgerID BE 4B]\x02[txID 8B]` | +| `0x01` | Volumes | `[ledgerName 64B][account]\x00[color]\x00[asset_base][precision]` | +| `0x02` | Account Metadata | `[ledgerName 64B][account]\x01[key]` | +| `0x03` | Transaction State | `[ledgerName 64B]\x02[txID 8B]` | | `0x04` | Ledger Info | `[ledger name]` | | `0x05` | Boundaries | `[ledger name]` | -| `0x06` | References | `[ledgerID BE 4B][reference]` | -| `0x07` | Ledger Metadata | `[ledgerID BE 4B]\x01[key]` | +| `0x06` | References | `[ledgerName 64B][reference]` | +| `0x07` | Ledger Metadata | `[ledgerName 64B]\x01[key]` | | `0x08` | Sink Configs | `[name]` | -| `0x09` | Numscript Versions | `[ledgerID BE 4B][name]` | -| `0x0A` | Numscript Contents | `[ledgerID BE 4B][name]\x00[version]` | -| `0x0B` | Prepared Queries | `[ledgerID BE 4B][name]` | +| `0x09` | Numscript Versions | `[ledgerName 64B][name]` | +| `0x0A` | Numscript Contents | `[ledgerName 64B][name]\x00[version]` | +| `0x0B` | Prepared Queries | `[ledgerName 64B][name]` | -> **Note:** `LedgerKey` (used for Ledger Info and Boundaries) uses the string-based ledger name, because those entries are looked up by name. All other ledger-scoped keys use the numeric `uint32` ledger ID (big-endian, 4 bytes) for compact encoding and to support future ledger renames without rewriting data. +> **Note:** All ledger-scoped attribute keys are prefixed by the fixed-width **64-byte, zero-padded ledger name** (`LedgerScopedPrefix`), which gives uniform prefix-scan semantics (e.g. scanning by `(ledgerName, account)` returns every color of a volume). `LedgerKey` (Ledger Info and Boundaries) uses the bare ledger-name string, because those entries are looked up by name. For volumes the color is placed **between** account and asset (`[account]\x00[color]\x00[asset_base]`) so a `(ledgerName, account)` prefix scan still returns all colors of an account. See [System Attributes](../attributes/attributes.md) and [Attribute Key Hashing](../attributes/key-hashing.md) for the caching model and U128 hash key system. @@ -165,7 +165,7 @@ Singleton keys for system-wide state: Volumes use **last-write-wins** semantics with a single entry per canonical key: ``` -Key: [0x01][0x01][ledgerID BE 4B][account]\x00[asset_base][precision] +Key: [0x01][0x01][ledgerName 64B][account]\x00[color]\x00[asset_base][precision] Value: VolumePair protobuf (Input + Output as Uint256) ``` diff --git a/docs/technical/contributing/api-comparison.md b/docs/technical/contributing/api-comparison.md index 02fd9e1399..cde2ce8fc9 100644 --- a/docs/technical/contributing/api-comparison.md +++ b/docs/technical/contributing/api-comparison.md @@ -144,12 +144,36 @@ This document compares the POC's API with the original Formance ledger API and d **Numscript Experimental Features (available, require `#![feature(...)]` opt-in):** - ✅ Account interpolation (dynamic addresses like `@escrow:$order_id`) -- ✅ Asset colors (fund origin tracking) +- ✅ Asset colors — promoted to first-class posting field. Postings carry + `color: string` and balances are strictly segregated per + `(account, asset, color)`. The empty color is the uncolored bucket and + is itself segregated from every colored bucket. Color values match + `^[A-Z]*$` and are immutable once carried by funds. See "Color of money + semantics" below. - ✅ `get_amount()` / `get_asset()` functions - ✅ Mid-script function calls (balance queries during execution) - ✅ `oneof` selector (conditional routing) - ✅ `overdraft()` function (dynamic overdraft calculation) +**Color of money semantics (new in this POC):** +- `Posting.color` is exposed on every read/write path. Direct postings + accept the new field as the optional fifth component of the + `--posting source,destination,amount,asset[,color]` syntax on + `ledgerctl transactions create`. +- `Account.volumes` is a deterministic sorted list of + `{asset, color, volumes}` entries. The HTTP query parameter + `?collapseColors=true` on `GET /{ledger}/accounts/{address}` sums every + colored bucket of the same asset into a single entry under `color: ""`. +- `AggregatedVolume.color` is set on every entry returned by + `GET /{ledger}/volumes`. The same `?collapseColors=true` flag collapses + the result to one entry per `(asset, precision)`. +- The double-entry invariant holds per `(asset, color)` bucket: each + segregated bucket is its own conservation universe. +- Numscript `source = @acc \ "RED"` produces a `Posting` with + `Color = "RED"` and only draws from the matching bucket. Spending more + than the bucket holds returns `ErrInsufficientFunds` even when other + colored or uncolored buckets have plenty. + See [Numscript Guide](./numscript.md) for complete documentation. ### 2. Transaction Revert diff --git a/docs/technical/contributing/numscript.md b/docs/technical/contributing/numscript.md index fc0cd94ed3..4e26217e15 100644 --- a/docs/technical/contributing/numscript.md +++ b/docs/technical/contributing/numscript.md @@ -145,9 +145,12 @@ This creates a transaction sending funds to `escrow:order-12345`. Track the origin of funds using asset coloring: ```numscript -// Send specifically colored funds -send [USD/2#promo 100] ( - source = @marketing:budget +#![feature("experimental-asset-colors")] + +// Send specifically colored funds: the color qualifies the source bucket. +// Colors are uppercase (validated against ^[A-Z]*$). +send [USD/2 100] ( + source = @marketing:budget \ "PROMO" destination = @users:alice ) ``` diff --git a/go.mod b/go.mod index aca3d7b4b1..083dc100f4 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/databricks/databricks-sql-go v1.10.0 github.com/formancehq/go-libs/v5 v5.7.0 github.com/formancehq/invariants v0.11.0 - github.com/formancehq/numscript v0.0.24 + github.com/formancehq/numscript v0.0.25-0.20260615131322-cbc11e844233 github.com/fsnotify/fsnotify v1.5.4 github.com/go-chi/chi/v5 v5.2.5 github.com/go-jose/go-jose/v4 v4.1.4 @@ -163,7 +163,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/getkin/kin-openapi v0.134.0 // indirect - github.com/getsentry/sentry-go v0.35.1 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-faster/city v1.0.1 // indirect @@ -303,7 +303,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index 9b4a45cf57..79020885e0 100644 --- a/go.sum +++ b/go.sum @@ -244,24 +244,22 @@ github.com/formancehq/go-libs/v5 v5.7.0 h1:2Z2S3vtOJr45tKpofhPqd0qKrPr6KB/LZZ+Ef github.com/formancehq/go-libs/v5 v5.7.0/go.mod h1:+jfCYWJ4Z10NGbhmbfon0hGoLe5pysbVQgePrg8M8W4= github.com/formancehq/invariants v0.11.0 h1:AHFBhK7U8Ak/qoJwgaNU+cAb9w61Ba7dwlcrJTkWBE4= github.com/formancehq/invariants v0.11.0/go.mod h1:ywzSexCUdhw2dC9Njiv0t4u2KfPVKj6SecFujPgmXno= -github.com/formancehq/numscript v0.0.24 h1:YBiDZ9zLVxTZVhtQ+taRcb6q2jArAvznWMfoWRVYGT0= -github.com/formancehq/numscript v0.0.24/go.mod h1:hC/VY5Vg04F5QkgdPPc6z/YsS/vh8V1qVJVa1VWnYMA= +github.com/formancehq/numscript v0.0.25-0.20260615131322-cbc11e844233 h1:uRt0sq1nV+emELMBtKvgHhb0aERyioLl7v9rJwINhWw= +github.com/formancehq/numscript v0.0.25-0.20260615131322-cbc11e844233/go.mod h1:DUR23FgL3NVEYvjr8KHt5bGFDpcheydwbvs+iTGohAU= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= -github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ= -github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= -github.com/gkampitakis/ciinfo v0.3.3 h1:28PgAHtW3wG7UCAKuCK+17rBib9iqtLjajuWsVLUPQY= -github.com/gkampitakis/ciinfo v0.3.3/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z0KY= +github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-snaps v0.5.21 h1:SvhSFeZviQXwlT+dnGyAIATVehkhqRVW6qfQZhCZH+Y= +github.com/gkampitakis/go-snaps v0.5.21/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -299,8 +297,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -439,8 +437,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= +github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -780,8 +778,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/adapter/grpc/client_bucket.go b/internal/adapter/grpc/client_bucket.go index 29e904c2de..a12a461e25 100644 --- a/internal/adapter/grpc/client_bucket.go +++ b/internal/adapter/grpc/client_bucket.go @@ -96,10 +96,11 @@ func (g *BucketGrpcClient) ListTransactions(ctx context.Context, ledgerName stri return NewUpstreamPeekCursor(stream), nil } -func (g *BucketGrpcClient) GetAccount(ctx context.Context, ledgerName string, address string) (*commonpb.Account, error) { +func (g *BucketGrpcClient) GetAccount(ctx context.Context, ledgerName string, address string, opts ctrl.GetAccountOptions) (*commonpb.Account, error) { return g.client.GetAccount(ctx, &servicepb.GetAccountRequest{ - Ledger: ledgerName, - Address: address, + Ledger: ledgerName, + Address: address, + CollapseColors: opts.CollapseColors, }) } @@ -383,6 +384,7 @@ func (g *BucketGrpcClient) AggregateVolumes(ctx context.Context, ledgerName stri Ledger: ledgerName, Filter: filter, UseMaxPrecision: opts.UseMaxPrecision, + CollapseColors: opts.CollapseColors, GroupByPrefixes: opts.GroupByPrefixes, }) } diff --git a/internal/adapter/grpc/client_bucket_test.go b/internal/adapter/grpc/client_bucket_test.go index 62e963e223..d25ba8bd8b 100644 --- a/internal/adapter/grpc/client_bucket_test.go +++ b/internal/adapter/grpc/client_bucket_test.go @@ -14,6 +14,7 @@ import ( "github.com/formancehq/go-libs/v5/pkg/authn/oidc" "github.com/formancehq/ledger/v3/internal/adapter/auth" + appctrl "github.com/formancehq/ledger/v3/internal/application/ctrl" "github.com/formancehq/ledger/v3/internal/proto/auditpb" "github.com/formancehq/ledger/v3/internal/proto/commonpb" "github.com/formancehq/ledger/v3/internal/proto/servicepb" @@ -199,7 +200,7 @@ func TestGetAccount_Success(t *testing.T) { mock.EXPECT().GetAccount(gomock.Any(), gomock.Any()).Return(expected, nil) client := NewLedgerGrpcClient(mock) - account, err := client.GetAccount(context.Background(), "ledger1", "user:001") + account, err := client.GetAccount(context.Background(), "ledger1", "user:001", appctrl.GetAccountOptions{}) require.NoError(t, err) require.Equal(t, "user:001", account.GetAddress()) } @@ -212,7 +213,7 @@ func TestGetAccount_ReturnsError(t *testing.T) { mock.EXPECT().GetAccount(gomock.Any(), gomock.Any()).Return(nil, errors.New("unavailable")) client := NewLedgerGrpcClient(mock) - _, err := client.GetAccount(context.Background(), "ledger1", "user:001") + _, err := client.GetAccount(context.Background(), "ledger1", "user:001", appctrl.GetAccountOptions{}) require.Error(t, err) } diff --git a/internal/adapter/grpc/controller_generated_test.go b/internal/adapter/grpc/controller_generated_test.go index 414506c2cf..26698dee8a 100644 --- a/internal/adapter/grpc/controller_generated_test.go +++ b/internal/adapter/grpc/controller_generated_test.go @@ -11,6 +11,7 @@ import ( context "context" reflect "reflect" + ctrl "github.com/formancehq/ledger/v3/internal/application/ctrl" cursor "github.com/formancehq/ledger/v3/internal/pkg/cursor" auditpb "github.com/formancehq/ledger/v3/internal/proto/auditpb" commonpb "github.com/formancehq/ledger/v3/internal/proto/commonpb" @@ -278,18 +279,18 @@ func (c *MockControllerExecutePreparedQueryCall) DoAndReturn(f func(context.Cont } // GetAccount mocks base method. -func (m *MockController) GetAccount(ctx context.Context, ledgerName, address string) (*commonpb.Account, error) { +func (m *MockController) GetAccount(ctx context.Context, ledgerName, address string, opts ctrl.GetAccountOptions) (*commonpb.Account, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address) + ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address, opts) ret0, _ := ret[0].(*commonpb.Account) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccount indicates an expected call of GetAccount. -func (mr *MockControllerMockRecorder) GetAccount(ctx, ledgerName, address any) *MockControllerGetAccountCall { +func (mr *MockControllerMockRecorder) GetAccount(ctx, ledgerName, address, opts any) *MockControllerGetAccountCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockController)(nil).GetAccount), ctx, ledgerName, address) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockController)(nil).GetAccount), ctx, ledgerName, address, opts) return &MockControllerGetAccountCall{Call: call} } @@ -305,13 +306,13 @@ func (c *MockControllerGetAccountCall) Return(arg0 *commonpb.Account, arg1 error } // Do rewrite *gomock.Call.Do -func (c *MockControllerGetAccountCall) Do(f func(context.Context, string, string) (*commonpb.Account, error)) *MockControllerGetAccountCall { +func (c *MockControllerGetAccountCall) Do(f func(context.Context, string, string, ctrl.GetAccountOptions) (*commonpb.Account, error)) *MockControllerGetAccountCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockControllerGetAccountCall) DoAndReturn(f func(context.Context, string, string) (*commonpb.Account, error)) *MockControllerGetAccountCall { +func (c *MockControllerGetAccountCall) DoAndReturn(f func(context.Context, string, string, ctrl.GetAccountOptions) (*commonpb.Account, error)) *MockControllerGetAccountCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/adapter/grpc/server_bucket.go b/internal/adapter/grpc/server_bucket.go index 68f748923a..eb3c190607 100644 --- a/internal/adapter/grpc/server_bucket.go +++ b/internal/adapter/grpc/server_bucket.go @@ -754,7 +754,9 @@ func (impl *BucketServiceServerImpl) GetAccount(ctx context.Context, req *servic } defer cleanup() - return c.GetAccount(ctx, req.GetLedger(), req.GetAddress()) + return c.GetAccount(ctx, req.GetLedger(), req.GetAddress(), ctrl.GetAccountOptions{ + CollapseColors: req.GetCollapseColors(), + }) } func (impl *BucketServiceServerImpl) ListAccounts(req *servicepb.ListAccountsRequest, stream servicepb.BucketService_ListAccountsServer) error { @@ -1400,6 +1402,7 @@ func (impl *BucketServiceServerImpl) AggregateVolumes(ctx context.Context, req * result, err := c.AggregateVolumes(profileCtx, req.GetLedger(), req.GetFilter(), query.AggregateOptions{ UseMaxPrecision: req.GetUseMaxPrecision(), GroupByPrefixes: req.GetGroupByPrefixes(), + CollapseColors: req.GetCollapseColors(), }) impl.emitProfile(ctx, profile) diff --git a/internal/adapter/http/backend_generated_test.go b/internal/adapter/http/backend_generated_test.go index 16e832a1d9..ed92321b7c 100644 --- a/internal/adapter/http/backend_generated_test.go +++ b/internal/adapter/http/backend_generated_test.go @@ -11,6 +11,7 @@ import ( context "context" reflect "reflect" + ctrl "github.com/formancehq/ledger/v3/internal/application/ctrl" cursor "github.com/formancehq/ledger/v3/internal/pkg/cursor" auditpb "github.com/formancehq/ledger/v3/internal/proto/auditpb" clusterpb "github.com/formancehq/ledger/v3/internal/proto/clusterpb" @@ -279,18 +280,18 @@ func (c *MockBackendExecutePreparedQueryCall) DoAndReturn(f func(context.Context } // GetAccount mocks base method. -func (m *MockBackend) GetAccount(ctx context.Context, ledgerName, address string) (*commonpb.Account, error) { +func (m *MockBackend) GetAccount(ctx context.Context, ledgerName, address string, opts ctrl.GetAccountOptions) (*commonpb.Account, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address) + ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address, opts) ret0, _ := ret[0].(*commonpb.Account) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccount indicates an expected call of GetAccount. -func (mr *MockBackendMockRecorder) GetAccount(ctx, ledgerName, address any) *MockBackendGetAccountCall { +func (mr *MockBackendMockRecorder) GetAccount(ctx, ledgerName, address, opts any) *MockBackendGetAccountCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockBackend)(nil).GetAccount), ctx, ledgerName, address) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockBackend)(nil).GetAccount), ctx, ledgerName, address, opts) return &MockBackendGetAccountCall{Call: call} } @@ -306,13 +307,13 @@ func (c *MockBackendGetAccountCall) Return(arg0 *commonpb.Account, arg1 error) * } // Do rewrite *gomock.Call.Do -func (c *MockBackendGetAccountCall) Do(f func(context.Context, string, string) (*commonpb.Account, error)) *MockBackendGetAccountCall { +func (c *MockBackendGetAccountCall) Do(f func(context.Context, string, string, ctrl.GetAccountOptions) (*commonpb.Account, error)) *MockBackendGetAccountCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockBackendGetAccountCall) DoAndReturn(f func(context.Context, string, string) (*commonpb.Account, error)) *MockBackendGetAccountCall { +func (c *MockBackendGetAccountCall) DoAndReturn(f func(context.Context, string, string, ctrl.GetAccountOptions) (*commonpb.Account, error)) *MockBackendGetAccountCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/adapter/http/handlers_aggregate_volumes.go b/internal/adapter/http/handlers_aggregate_volumes.go index 1fb019bff8..f4c4f7b613 100644 --- a/internal/adapter/http/handlers_aggregate_volumes.go +++ b/internal/adapter/http/handlers_aggregate_volumes.go @@ -16,7 +16,11 @@ type aggregateVolumesResponseJSON struct { } type aggregatedVolumeJSON struct { - Asset string `json:"asset"` + Asset string `json:"asset"` + // Color is always emitted (even when empty) so clients can distinguish + // the uncolored bucket from an older response shape that didn't carry + // the color dimension at all. + Color string `json:"color"` Input string `json:"input"` Output string `json:"output"` Balance string `json:"balance"` @@ -34,6 +38,7 @@ func toAggregatedVolumeJSON(v *commonpb.AggregatedVolume) *aggregatedVolumeJSON return &aggregatedVolumeJSON{ Asset: v.GetAsset(), + Color: v.GetColor(), Input: input.String(), Output: output.String(), Balance: balance.String(), @@ -74,6 +79,7 @@ func (s *Server) handleAggregateVolumes(w http.ResponseWriter, r *http.Request) } useMaxPrecision := queryParamBool(r, "useMaxPrecision") + collapseColors := queryParamBool(r, "collapseColors") var groupByPrefixes []string if g := r.URL.Query().Get("groupByPrefixes"); g != "" { @@ -96,6 +102,7 @@ func (s *Server) handleAggregateVolumes(w http.ResponseWriter, r *http.Request) result, err := s.backend.AggregateVolumes(ctx, ledgerName, filter, query.AggregateOptions{ UseMaxPrecision: useMaxPrecision, + CollapseColors: collapseColors, GroupByPrefixes: groupByPrefixes, }) if err != nil { diff --git a/internal/adapter/http/handlers_aggregate_volumes_test.go b/internal/adapter/http/handlers_aggregate_volumes_test.go index 5d8c419aa1..06a73947c0 100644 --- a/internal/adapter/http/handlers_aggregate_volumes_test.go +++ b/internal/adapter/http/handlers_aggregate_volumes_test.go @@ -235,3 +235,40 @@ func TestHandleAggregateVolumes_FullRouteIntegration(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) } + +// TestHandleAggregateVolumes_EmitsColorAlways pins the wire shape: the +// `color` field is present on every aggregate entry, including for the +// uncolored bucket (empty string). The OpenAPI contract documents +// color as first-class; an `omitempty` tag would drop the field exactly +// when color="" and break clients that expect it on every entry. +func TestHandleAggregateVolumes_EmitsColorAlways(t *testing.T) { + t.Parallel() + + backend := NewMockBackend(gomock.NewController(t)) + backend.EXPECT().AggregateVolumes(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, _ *commonpb.QueryFilter, opts query.AggregateOptions) (*commonpb.AggregateResult, error) { + require.True(t, opts.CollapseColors, "?collapseColors=true must reach the backend") + + return &commonpb.AggregateResult{ + Volumes: []*commonpb.AggregatedVolume{ + { + Asset: "USD/2", + Color: "", // uncolored / collapsed bucket + Input: commonpb.NewUint256FromUint64(100), + Output: commonpb.NewUint256FromUint64(30), + }, + }, + }, nil + }) + + handler := NewHandler(logging.Testing(), backend, internalauth.AuthConfig{}, version.Info{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v3/my-ledger/volumes?collapseColors=true", nil) + + handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"color":""`, + `empty color must surface as "color":"" not be omitted by omitempty`) +} diff --git a/internal/adapter/http/handlers_analyze_transactions.go b/internal/adapter/http/handlers_analyze_transactions.go index f8de7f7442..53404ea197 100644 --- a/internal/adapter/http/handlers_analyze_transactions.go +++ b/internal/adapter/http/handlers_analyze_transactions.go @@ -29,6 +29,11 @@ type normalizedPostingJSON struct { SourcePattern string `json:"sourcePattern"` DestinationPattern string `json:"destinationPattern"` Asset string `json:"asset"` + // Color is always emitted (even when empty) so REST clients can + // distinguish patterns that differ only by color bucket. Two flows + // with the same (source, destination, asset) but different colors + // surface here as distinct rows. + Color string `json:"color"` } type temporalStatsJSON struct { @@ -95,6 +100,7 @@ func toFlowPatternJSON(fp *servicepb.FlowPattern) *flowPatternJSON { SourcePattern: p.GetSourcePattern(), DestinationPattern: p.GetDestinationPattern(), Asset: p.GetAsset(), + Color: p.GetColor(), }) } diff --git a/internal/adapter/http/handlers_coverage_test.go b/internal/adapter/http/handlers_coverage_test.go index b011a1f327..2e9e60694a 100644 --- a/internal/adapter/http/handlers_coverage_test.go +++ b/internal/adapter/http/handlers_coverage_test.go @@ -15,6 +15,7 @@ import ( logging "github.com/formancehq/go-libs/v5/pkg/observe/log" internalauth "github.com/formancehq/ledger/v3/internal/adapter/auth" + "github.com/formancehq/ledger/v3/internal/application/ctrl" "github.com/formancehq/ledger/v3/internal/domain" "github.com/formancehq/ledger/v3/internal/pkg/cursor" "github.com/formancehq/ledger/v3/internal/pkg/version" @@ -533,8 +534,8 @@ func TestHandleGetAccount_GetAccountError(t *testing.T) { func(_ context.Context, _ string) (*commonpb.LedgerInfo, error) { return &commonpb.LedgerInfo{Name: "ledger1"}, nil }).AnyTimes() - backend.EXPECT().GetAccount(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, _ string, _ string) (*commonpb.Account, error) { + backend.EXPECT().GetAccount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, _ string, _ ctrl.GetAccountOptions) (*commonpb.Account, error) { return nil, errors.New("storage error") }).AnyTimes() srv := newTestServer(t, backend) diff --git a/internal/adapter/http/handlers_execute_prepared_query.go b/internal/adapter/http/handlers_execute_prepared_query.go index 073b733a2f..7c90d196c8 100644 --- a/internal/adapter/http/handlers_execute_prepared_query.go +++ b/internal/adapter/http/handlers_execute_prepared_query.go @@ -102,7 +102,36 @@ func (s *Server) handleExecutePreparedQuery(w http.ResponseWriter, r *http.Reque writeProfileHeader(w, profile) } - writeJSONResponse(w, http.StatusOK, resp) + // Serialize into a clean, discriminated camelCase envelope instead of the + // raw proto. ExecutePreparedQueryResponse has no MarshalJSON, so writing it + // raw leaks the Go oneof shape as PascalCase `{"Result":{"Cursor":…}}` / + // `{"Result":{"Aggregate":…}}`, violating the camelCase JSON invariant. The + // envelope keeps the variant discriminator (`cursor` vs `aggregateResult`) + // — the shape EN-1465 tracks — and reuses the existing correct marshalers: + // - aggregateResult goes through the same camelCase DTO the dedicated + // /aggregate handler uses (toAggregateVolumesJSON), which always emits + // `color` even for the uncolored bucket (the raw proto AggregatedVolume + // tag is `json:"color,omitempty"` and would otherwise drop it); + // - cursor uses the hand-written PreparedQueryCursor.MarshalJSON whose + // nested Account/Transaction marshalers already emit camelCase, + // decimal-string amounts, and `color` on every volume row. + envelope := executePreparedQueryResponseJSON{} + switch result := resp.GetResult().(type) { + case *servicepb.ExecutePreparedQueryResponse_Aggregate: + envelope.AggregateResult = toAggregateVolumesJSON(result.Aggregate) + case *servicepb.ExecutePreparedQueryResponse_Cursor: + envelope.Cursor = result.Cursor + } + + writeJSONResponse(w, http.StatusOK, envelope) +} + +// executePreparedQueryResponseJSON is the clean camelCase envelope for the +// prepared-query result oneof: exactly one of cursor / aggregateResult is set +// (both omitempty), replacing the leaked PascalCase proto oneof shape. +type executePreparedQueryResponseJSON struct { + Cursor *commonpb.PreparedQueryCursor `json:"cursor,omitempty"` + AggregateResult *aggregateVolumesResponseJSON `json:"aggregateResult,omitempty"` } // convertJSONParameters converts raw JSON values into typed ParameterValue messages. diff --git a/internal/adapter/http/handlers_execute_prepared_query_test.go b/internal/adapter/http/handlers_execute_prepared_query_test.go index b3d6f816f3..8809d86b42 100644 --- a/internal/adapter/http/handlers_execute_prepared_query_test.go +++ b/internal/adapter/http/handlers_execute_prepared_query_test.go @@ -11,6 +11,7 @@ import ( "go.uber.org/mock/gomock" "github.com/formancehq/ledger/v3/internal/domain" + "github.com/formancehq/ledger/v3/internal/proto/commonpb" "github.com/formancehq/ledger/v3/internal/proto/servicepb" ) @@ -218,6 +219,128 @@ func TestHandleExecutePreparedQuery_UnknownMode(t *testing.T) { require.Contains(t, w.Body.String(), "BOGUS") } +// TestHandleExecutePreparedQuery_AggregateEmitsColor pins that the aggregate +// variant is serialized under the `aggregateResult` envelope key through the +// same camelCase DTO as the dedicated /aggregate handler: `color` is always +// present (including the uncolored bucket, as ""), amounts are decimal strings, +// and a balance is computed. The previous raw-proto serialization dropped +// `color` on uncolored rows (json:"color,omitempty") and leaked PascalCase +// oneof wrapper keys. +func TestHandleExecutePreparedQuery_AggregateEmitsColor(t *testing.T) { + t.Parallel() + + backend := NewMockBackend(gomock.NewController(t)) + backend.EXPECT().ExecutePreparedQuery(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ *servicepb.ExecutePreparedQueryRequest) (*servicepb.ExecutePreparedQueryResponse, error) { + return &servicepb.ExecutePreparedQueryResponse{ + Result: &servicepb.ExecutePreparedQueryResponse_Aggregate{ + Aggregate: &commonpb.AggregateResult{ + Volumes: []*commonpb.AggregatedVolume{ + {Asset: "USD", Color: "", Input: commonpb.NewUint256FromUint64(100), Output: commonpb.NewUint256FromUint64(30)}, + {Asset: "USD", Color: "RED", Input: commonpb.NewUint256FromUint64(50), Output: commonpb.NewUint256FromUint64(0)}, + }, + }, + }, + }, nil + }).AnyTimes() + srv := newTestServer(t, backend) + + w := httptest.NewRecorder() + r := newRequest(t, http.MethodPost, "/ledger1/prepared-queries/my-query/execute", + strings.NewReader(`{"mode":"AGGREGATE_VOLUMES"}`), + map[string]string{ + "ledgerName": "ledger1", + "queryName": "my-query", + }) + + srv.handleExecutePreparedQuery(w, r) + + require.Equal(t, http.StatusOK, w.Code) + + resp := decodeResponse[executePreparedQueryResponseJSON](t, w) + require.Nil(t, resp.Cursor, "aggregate response must not set the cursor variant") + require.NotNil(t, resp.AggregateResult) + vols := resp.AggregateResult.Volumes + require.Len(t, vols, 2) + + // Uncolored bucket: color present as "" (not omitted), amounts as strings. + require.Equal(t, "", vols[0].Color) + require.Equal(t, "USD", vols[0].Asset) + require.Equal(t, "100", vols[0].Input) + require.Equal(t, "30", vols[0].Output) + require.Equal(t, "70", vols[0].Balance) + + // Colored bucket carries its color verbatim. + require.Equal(t, "RED", vols[1].Color) + require.Equal(t, "50", vols[1].Balance) + + // The raw body must be the camelCase `aggregateResult` envelope, carry the + // `color` key for the uncolored row, and must NOT leak the PascalCase oneof. + body := w.Body.String() + require.Contains(t, body, `"aggregateResult"`) + require.Contains(t, body, `"color":""`) + require.NotContains(t, body, `"Result"`) + require.NotContains(t, body, `"Aggregate"`) +} + +// TestHandleExecutePreparedQuery_CursorShapeIsCamelCase pins that the LIST / +// cursor variant is serialized under the `cursor` envelope key via +// PreparedQueryCursor.MarshalJSON, not wrapped in the PascalCase Go oneof +// envelope. The nested account volume row must carry camelCase keys, +// decimal-string amounts, and `color` present even for the uncolored bucket. +func TestHandleExecutePreparedQuery_CursorShapeIsCamelCase(t *testing.T) { + t.Parallel() + + backend := NewMockBackend(gomock.NewController(t)) + backend.EXPECT().ExecutePreparedQuery(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ *servicepb.ExecutePreparedQueryRequest) (*servicepb.ExecutePreparedQueryResponse, error) { + return &servicepb.ExecutePreparedQueryResponse{ + Result: &servicepb.ExecutePreparedQueryResponse_Cursor{ + Cursor: &commonpb.PreparedQueryCursor{ + PageSize: 15, + HasMore: true, + Next: "nxt", + AccountData: []*commonpb.Account{ + {Address: "alice", Volumes: []*commonpb.AccountVolume{ + {Asset: "USD", Color: "", Volumes: &commonpb.VolumesWithBalance{Input: "100", Output: "30", Balance: "70"}}, + }}, + }, + }, + }, + }, nil + }).AnyTimes() + srv := newTestServer(t, backend) + + w := httptest.NewRecorder() + r := newRequest(t, http.MethodPost, "/ledger1/prepared-queries/my-query/execute", + strings.NewReader(`{"pageSize":15}`), + map[string]string{ + "ledgerName": "ledger1", + "queryName": "my-query", + }) + + srv.handleExecutePreparedQuery(w, r) + + require.Equal(t, http.StatusOK, w.Code) + + body := w.Body.String() + // No PascalCase Go oneof wrapper. + require.NotContains(t, body, `"Result"`) + require.NotContains(t, body, `"Cursor"`) + // camelCase `cursor` envelope key, no `aggregateResult` for this variant. + require.Contains(t, body, `"cursor"`) + require.NotContains(t, body, `"aggregateResult"`) + // camelCase cursor fields. + require.Contains(t, body, `"pageSize":15`) + require.Contains(t, body, `"hasMore":true`) + require.Contains(t, body, `"accountData"`) + // color is present on the uncolored account-volume row (not dropped). + require.Contains(t, body, `"color":""`) + // Amounts are decimal strings, not raw numbers. + require.Contains(t, body, `"input":"100"`) + require.Contains(t, body, `"balance":"70"`) +} + func TestHandleExecutePreparedQuery_UnsupportedParameterType(t *testing.T) { t.Parallel() diff --git a/internal/adapter/http/handlers_get_account.go b/internal/adapter/http/handlers_get_account.go index 9c63657e24..ad96e97373 100644 --- a/internal/adapter/http/handlers_get_account.go +++ b/internal/adapter/http/handlers_get_account.go @@ -5,9 +5,13 @@ import ( "net/http" "github.com/go-chi/chi/v5" + + "github.com/formancehq/ledger/v3/internal/application/ctrl" ) // handleGetAccount handles GET /{ledgerName}/accounts/{address} to retrieve an account. +// The optional ?collapseColors=true query param sums every colored bucket of +// the same asset into a single entry with color="" in the response. func (s *Server) handleGetAccount(w http.ResponseWriter, r *http.Request) { ledgerName, ok := requireLedgerName(w, r) if !ok { @@ -29,7 +33,11 @@ func (s *Server) handleGetAccount(w http.ResponseWriter, r *http.Request) { return } - account, err := s.backend.GetAccount(r.Context(), ledgerName, address) + opts := ctrl.GetAccountOptions{ + CollapseColors: queryParamBool(r, "collapseColors"), + } + + account, err := s.backend.GetAccount(r.Context(), ledgerName, address, opts) if err != nil { s.logger.WithFields(map[string]any{ "ledger": ledgerName, diff --git a/internal/adapter/http/handlers_get_account_test.go b/internal/adapter/http/handlers_get_account_test.go index 89ba99a203..35e8be1b43 100644 --- a/internal/adapter/http/handlers_get_account_test.go +++ b/internal/adapter/http/handlers_get_account_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/formancehq/ledger/v3/internal/application/ctrl" "github.com/formancehq/ledger/v3/internal/proto/commonpb" ) @@ -20,8 +21,8 @@ func TestHandleGetAccount_Success(t *testing.T) { func(_ context.Context, _ string) (*commonpb.LedgerInfo, error) { return &commonpb.LedgerInfo{Name: "ledger1"}, nil }).AnyTimes() - backend.EXPECT().GetAccount(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, _ string, addr string) (*commonpb.Account, error) { + backend.EXPECT().GetAccount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, addr string, _ ctrl.GetAccountOptions) (*commonpb.Account, error) { return &commonpb.Account{Address: addr}, nil }).AnyTimes() srv := newTestServer(t, backend) @@ -37,6 +38,94 @@ func TestHandleGetAccount_Success(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) } +// TestHandleGetAccount_PropagatesCollapseColors pins that the HTTP handler +// forwards `?collapseColors=true` through to the backend as +// GetAccountOptions.CollapseColors=true. A regression where the handler stops +// parsing or forwarding the field would fail the matcher on EXPECT().GetAccount. +func TestHandleGetAccount_PropagatesCollapseColors(t *testing.T) { + t.Parallel() + + backend := NewMockBackend(gomock.NewController(t)) + backend.EXPECT().GetLedgerByName(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string) (*commonpb.LedgerInfo, error) { + return &commonpb.LedgerInfo{Name: "ledger1"}, nil + }).AnyTimes() + // The matcher rejects the call if opts.CollapseColors is not true. + backend.EXPECT().GetAccount(gomock.Any(), gomock.Any(), gomock.Any(), ctrl.GetAccountOptions{CollapseColors: true}). + DoAndReturn(func(_ context.Context, _ string, addr string, _ ctrl.GetAccountOptions) (*commonpb.Account, error) { + return &commonpb.Account{Address: addr}, nil + }) + srv := newTestServer(t, backend) + + w := httptest.NewRecorder() + r := newRequest(t, http.MethodGet, "/ledger1/accounts/alice?collapseColors=true", nil, map[string]string{ + "ledgerName": "ledger1", + "address": "alice", + }) + + srv.handleGetAccount(w, r) + + require.Equal(t, http.StatusOK, w.Code) +} + +// TestHandleGetAccount_VolumesInJSON pins that Account.MarshalJSON emits the +// Volumes array with color:"" for the uncolored bucket. Without this +// assertion a marshaller regression that drops the Volumes field (the +// previous behaviour) would slip through, since the handler test only +// looks at the status code. +func TestHandleGetAccount_VolumesInJSON(t *testing.T) { + t.Parallel() + + backend := NewMockBackend(gomock.NewController(t)) + backend.EXPECT().GetLedgerByName(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string) (*commonpb.LedgerInfo, error) { + return &commonpb.LedgerInfo{Name: "ledger1"}, nil + }).AnyTimes() + backend.EXPECT().GetAccount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, addr string, _ ctrl.GetAccountOptions) (*commonpb.Account, error) { + return &commonpb.Account{ + Address: addr, + Volumes: []*commonpb.AccountVolume{ + { + Asset: "USD/2", + Color: "", // uncolored bucket — must appear in JSON with color:"" + Volumes: &commonpb.VolumesWithBalance{ + Input: "100", + Output: "30", + Balance: "70", + }, + }, + { + Asset: "USD/2", + Color: "GRANTS", + Volumes: &commonpb.VolumesWithBalance{ + Input: "50", + Output: "0", + Balance: "50", + }, + }, + }, + }, nil + }).AnyTimes() + srv := newTestServer(t, backend) + + w := httptest.NewRecorder() + r := newRequest(t, http.MethodGet, "/ledger1/accounts/alice", nil, map[string]string{ + "ledgerName": "ledger1", + "address": "alice", + }) + + srv.handleGetAccount(w, r) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + require.Contains(t, body, `"volumes":[`, "Account JSON must include the volumes array") + require.Contains(t, body, `"asset":"USD/2"`) + require.Contains(t, body, `"color":""`, "the uncolored bucket must surface as color:\"\" not be omitted") + require.Contains(t, body, `"color":"GRANTS"`) + require.Contains(t, body, `"balance":"70"`) +} + func TestHandleGetAccount_MissingAddress(t *testing.T) { t.Parallel() diff --git a/internal/application/admission/admission.go b/internal/application/admission/admission.go index 222cb09775..2765abba73 100644 --- a/internal/application/admission/admission.go +++ b/internal/application/admission/admission.go @@ -1015,17 +1015,22 @@ func wrapSystemScoped(order *raftcmdpb.Order, ss *raftcmdpb.SystemScopedOrder) { order.Type = &raftcmdpb.Order_SystemScoped{SystemScoped: ss} } -// addVolumeNeed adds a volume key to the preload needs. Since EN-1378 a -// declared-but-absent volume key resolves to a `Declare` plan (pure -// coverage, no FSM-side cache mutation); the FSM-side `Scope.GetVolume` -// returns `domain.ErrNotFound` and callers treat it as a fresh zero -// balance (see `processing.readVolumeOrZero`). A `*state.ErrCoverageMiss` -// (admission contract violation — need never declared) stays distinct -// and propagates loud through `ErrStorageOperation{Cause: covErr}`. -func addVolumeNeed(p *plan.Coverage, ledgerName string, account, asset string) { +// addVolumeNeed adds a (account, asset, color) volume key to the preload +// needs. The empty color is the uncolored bucket; colored postings must +// request their own bucket so the FSM doesn't error. +// +// Since EN-1378 a declared-but-absent volume key resolves to a `Declare` +// plan (pure coverage, no FSM-side cache mutation); the FSM-side +// `Scope.GetVolume` returns `domain.ErrNotFound` and callers treat it +// as a fresh zero balance (see `processing.readVolumeOrZero`). A +// `*state.ErrCoverageMiss` (admission contract violation — need never +// declared) stays distinct and propagates loud through +// `ErrStorageOperation{Cause: covErr}`. +func addVolumeNeed(p *plan.Coverage, ledgerName, account, asset, color string) { p.Add(dal.SubAttrVolume, domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: account}, Asset: asset, + Color: color, }.Bytes()) } @@ -1078,9 +1083,11 @@ func extractLedgerScopedNeeds(p *plan.Coverage, ls *raftcmdpb.LedgerScopedOrder) postings = rt.GetReversePostings() } + // Mirror ingestion only handles v2 logs, which have no color + // dimension — every mirrored posting lands in the uncolored bucket. for _, posting := range postings { - addVolumeNeed(p, ledgerName, posting.GetSource(), posting.GetAsset()) - addVolumeNeed(p, ledgerName, posting.GetDestination(), posting.GetAsset()) + addVolumeNeed(p, ledgerName, posting.GetSource(), posting.GetAsset(), "") + addVolumeNeed(p, ledgerName, posting.GetDestination(), posting.GetAsset(), "") } if ct := mi.GetEntry().GetCreatedTransaction(); ct != nil { @@ -1205,8 +1212,8 @@ func extractLedgerScopedNeeds(p *plan.Coverage, ls *raftcmdpb.LedgerScopedOrder) if !scriptBacked { for _, posting := range applyData.CreateTransaction.GetPostings() { - addVolumeNeed(p, ledgerName, posting.GetSource(), posting.GetAsset()) - addVolumeNeed(p, ledgerName, posting.GetDestination(), posting.GetAsset()) + addVolumeNeed(p, ledgerName, posting.GetSource(), posting.GetAsset(), posting.GetColor()) + addVolumeNeed(p, ledgerName, posting.GetDestination(), posting.GetAsset(), posting.GetColor()) } } @@ -1214,8 +1221,8 @@ func extractLedgerScopedNeeds(p *plan.Coverage, ls *raftcmdpb.LedgerScopedOrder) addTransactionTargetNeeds(p, ledgerName, applyData.RevertTransaction.GetTransactionId()) for _, posting := range applyData.RevertTransaction.GetOriginalPostings() { - addVolumeNeed(p, ledgerName, posting.GetDestination(), posting.GetAsset()) - addVolumeNeed(p, ledgerName, posting.GetSource(), posting.GetAsset()) + addVolumeNeed(p, ledgerName, posting.GetDestination(), posting.GetAsset(), posting.GetColor()) + addVolumeNeed(p, ledgerName, posting.GetSource(), posting.GetAsset(), posting.GetColor()) } case *raftcmdpb.LedgerApplyOrder_AddMetadata: @@ -1460,13 +1467,13 @@ func (a *Admission) resolveScriptsAndEnrichNeeds(ctx context.Context, orders []* if discovered != nil { for key := range discovered.SourceVolumes { - addVolumeNeed(p, key.LedgerName, key.Account, key.Asset) - addVolumeNeed(orderNeeds, key.LedgerName, key.Account, key.Asset) + addVolumeNeed(p, key.LedgerName, key.Account, key.Asset, key.Color) + addVolumeNeed(orderNeeds, key.LedgerName, key.Account, key.Asset, key.Color) } for key := range discovered.DestinationVolumes { - addVolumeNeed(p, key.LedgerName, key.Account, key.Asset) - addVolumeNeed(orderNeeds, key.LedgerName, key.Account, key.Asset) + addVolumeNeed(p, key.LedgerName, key.Account, key.Asset, key.Color) + addVolumeNeed(orderNeeds, key.LedgerName, key.Account, key.Asset, key.Color) } for key := range discovered.WrittenMetadata { diff --git a/internal/application/admission/validate_order.go b/internal/application/admission/validate_order.go index 26ea6dda12..c155dc70c8 100644 --- a/internal/application/admission/validate_order.go +++ b/internal/application/admission/validate_order.go @@ -45,6 +45,47 @@ func validateOrder(order *raftcmdpb.Order) error { return &domain.BusinessError{Err: err} } + if err := validateOrderPostingColors(order); err != nil { + return &domain.BusinessError{Err: err} + } + + return nil +} + +// validateOrderPostingColors validates Color on every direct Posting attached +// to a CreateTransaction or RevertTransaction order, BEFORE admission extracts +// preload needs from those postings. +// +// Color flows directly from the request into the volume-key tuple admission +// uses for preload extraction, so a malformed value (e.g. `Color="A\x00B"`) +// would otherwise materialize a corrupted cache key before the FSM's own +// ValidateColor rejected the order. Validating here closes that window. +// +// Postings produced by Numscript still get their second-pass validation in +// the FSM (`validatePostings` in processor_transaction.go); the FSM stays the +// audit-trail enforcement layer for script-resolved postings. This admission +// check only covers the direct-postings shape that bypasses the producer. +func validateOrderPostingColors(order *raftcmdpb.Order) domain.Describable { + apply, ok := order.GetLedgerScoped().GetPayload().(*raftcmdpb.LedgerScopedOrder_Apply) + if !ok { + return nil + } + + switch d := apply.Apply.GetData().(type) { + case *raftcmdpb.LedgerApplyOrder_CreateTransaction: + for _, p := range d.CreateTransaction.GetPostings() { + if err := domain.ValidateColor(p.GetColor()); err != nil { + return err + } + } + case *raftcmdpb.LedgerApplyOrder_RevertTransaction: + for _, p := range d.RevertTransaction.GetOriginalPostings() { + if err := domain.ValidateColor(p.GetColor()); err != nil { + return err + } + } + } + return nil } diff --git a/internal/application/admission/validate_order_test.go b/internal/application/admission/validate_order_test.go index 4d0b6940ca..7bc2b144cb 100644 --- a/internal/application/admission/validate_order_test.go +++ b/internal/application/admission/validate_order_test.go @@ -1,6 +1,7 @@ package admission import ( + "math/big" "testing" "github.com/stretchr/testify/require" @@ -1129,6 +1130,58 @@ func TestValidateOrder_MirrorIAMRegion(t *testing.T) { } } +// TestValidateOrder_RejectsInvalidPostingColor guards the admission-side color +// validation that runs BEFORE preload extraction. A malformed Color value +// (lowercase, NUL byte, oversize) would otherwise reach addVolumeNeed and +// materialize a corrupted cache key before the FSM rejected the order. +func TestValidateOrder_RejectsInvalidPostingColor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + color string + wantErr bool + }{ + {name: "empty color (uncolored bucket)", color: "", wantErr: false}, + {name: "valid uppercase color", color: "GRANTS", wantErr: false}, + {name: "lowercase rejected", color: "grants", wantErr: true}, + {name: "embedded NUL rejected", color: "A\x00B", wantErr: true}, + {name: "mixed-case rejected", color: "Grants", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + order := &raftcmdpb.Order{ + Type: &raftcmdpb.Order_LedgerScoped{ + LedgerScoped: &raftcmdpb.LedgerScopedOrder{ + Ledger: "default", + Payload: &raftcmdpb.LedgerScopedOrder_Apply{ + Apply: &raftcmdpb.LedgerApplyOrder{ + Data: &raftcmdpb.LedgerApplyOrder_CreateTransaction{ + CreateTransaction: &raftcmdpb.CreateTransactionOrder{ + Postings: []*commonpb.Posting{ + commonpb.NewColoredPosting("world", "users:alice", "USD/2", tt.color, big.NewInt(100)), + }, + }, + }, + }, + }, + }, + }, + } + + err := validateOrder(order) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + // TestValidateOrder_MirrorIAMRejectsPGSSLMODEBypass reproduces the exact // bypass NumaryBot reported: pgxpool.ParseConfig folds PGSSLMODE from the // process env, so a DSN without sslmode= would inherit that env var and @@ -1163,3 +1216,29 @@ func TestValidateOrder_MirrorIAMRejectsPGSSLMODEBypass(t *testing.T) { require.ErrorIs(t, err, ErrMirrorIAMRequiresTLS, "PGSSLMODE=require in the pod env must not satisfy the admission TLS gate — the persisted DSN must carry sslmode= itself") } + +func TestValidateOrder_RejectsInvalidColorInRevert(t *testing.T) { + t.Parallel() + + order := &raftcmdpb.Order{ + Type: &raftcmdpb.Order_LedgerScoped{ + LedgerScoped: &raftcmdpb.LedgerScopedOrder{ + Ledger: "default", + Payload: &raftcmdpb.LedgerScopedOrder_Apply{ + Apply: &raftcmdpb.LedgerApplyOrder{ + Data: &raftcmdpb.LedgerApplyOrder_RevertTransaction{ + RevertTransaction: &raftcmdpb.RevertTransactionOrder{ + TransactionId: 42, + OriginalPostings: []*commonpb.Posting{ + commonpb.NewColoredPosting("users:alice", "world", "USD/2", "lowercase", big.NewInt(100)), + }, + }, + }, + }, + }, + }, + }, + } + + require.Error(t, validateOrder(order)) +} diff --git a/internal/application/check/checker.go b/internal/application/check/checker.go index 41802130e5..d8db8db9c2 100644 --- a/internal/application/check/checker.go +++ b/internal/application/check/checker.go @@ -302,13 +302,13 @@ func (c *Checker) Check(ctx context.Context, callback func(*servicepb.CheckStore // replay consumes — a tampered AppliedProposal.TransientVolumes or // LedgerLog.PurgedVolumes record cannot influence the integrity check. excluded := excludedVolumesSet{} - exclusionCollector := func(ledger, account, asset string) { + exclusionCollector := func(ledger, account, asset, color string) { set, exists := excluded[ledger] if !exists { set = make(map[domain.AccountAssetKey]struct{}) excluded[ledger] = set } - set[domain.AccountAssetKey{Account: account, Asset: asset}] = struct{}{} + set[domain.AccountAssetKey{Account: account, Asset: asset, Color: color}] = struct{}{} } // stored mirrors `excluded` but is built from the Pebble projections @@ -320,13 +320,13 @@ func (c *Checker) Check(ctx context.Context, callback func(*servicepb.CheckStore // indirectly relies on, so a tampered cache cannot make a corrupted // state look consistent. stored := excludedVolumesSet{} - addStored := func(ledger, account, asset string) { + addStored := func(ledger, account, asset, color string) { set, exists := stored[ledger] if !exists { set = make(map[domain.AccountAssetKey]struct{}) stored[ledger] = set } - set[domain.AccountAssetKey{Account: account, Asset: asset}] = struct{}{} + set[domain.AccountAssetKey{Account: account, Asset: asset, Color: color}] = struct{}{} } nextProposalEnd, hasProposalEnd, err := proposalBoundaries.Next() @@ -544,7 +544,7 @@ func (c *Checker) Check(ctx context.Context, callback func(*servicepb.CheckStore // AppliedProposal.TransientVolumes is added in a // single pass below. for _, v := range payload.Apply.GetLog().GetPurgedVolumes() { - addStored(ledgerName, v.GetAccount(), v.GetAsset()) + addStored(ledgerName, v.GetAccount(), v.GetAsset(), v.GetColor()) } } } @@ -668,14 +668,14 @@ func (c *Checker) Check(ctx context.Context, callback func(*servicepb.CheckStore } // collectStoredTransientVolumes walks the AppliedProposal stream and feeds -// every (ledger, account, asset) declared in TransientVolumes into the -// addStored callback. Paired with the LedgerLog.PurgedVolumes captured +// every (ledger, account, asset, color) declared in TransientVolumes into +// the addStored callback. Paired with the LedgerLog.PurgedVolumes captured // during the replay loop, this builds the "stored" projection the checker // compares against the audit-derived ground truth. func (c *Checker) collectStoredTransientVolumes( ctx context.Context, reader dal.PebbleReader, - addStored func(ledger, account, asset string), + addStored func(ledger, account, asset, color string), ) error { proposals, err := query.ReadAppliedProposals(ctx, reader, nil) if err != nil { @@ -696,7 +696,7 @@ func (c *Checker) collectStoredTransientVolumes( for ledgerName, volumeList := range entry.GetTransientVolumes() { for _, v := range volumeList.GetVolumes() { - addStored(ledgerName, v.GetAccount(), v.GetAsset()) + addStored(ledgerName, v.GetAccount(), v.GetAsset(), v.GetColor()) } } } @@ -1063,7 +1063,7 @@ func (c *Checker) compareMirrorV2LogID(reader dal.PebbleReader, chainBound *chai // index builder and cannot be trusted by the integrity checker. type excludedVolumesSet map[string]map[domain.AccountAssetKey]struct{} -func (e excludedVolumesSet) contains(ledgerName, account, asset string) bool { +func (e excludedVolumesSet) contains(ledgerName, account, asset, color string) bool { if e == nil { return false } @@ -1073,7 +1073,7 @@ func (e excludedVolumesSet) contains(ledgerName, account, asset string) bool { return false } - _, has := keys[domain.AccountAssetKey{Account: account, Asset: asset}] + _, has := keys[domain.AccountAssetKey{Account: account, Asset: asset, Color: color}] return has } @@ -1231,6 +1231,17 @@ func (c *Checker) compareVolumes(ctx context.Context, reader dal.PebbleReader, b var vk domain.VolumeKey if err := vk.Unmarshal([]byte(key)); err != nil { + // A persisted volume-projection key that no longer unmarshals is a + // corruption/tampering signal, not something to skip: silently + // continuing would let the malformed row escape verification entirely + // (invariant #7 — an impossible-by-design state must fail loudly). Emit + // a store error and move on; the key is unparseable, so there is no + // (ledger, account, asset) to compute an expectation against. + callback(errorEvent(servicepb.CheckStoreErrorType_CHECK_STORE_ERROR_TYPE_VOLUME_MISMATCH, + fmt.Sprintf("unparseable volume projection key %x: %v", key, err), 0, "", "", "")) + + errorCount++ + continue } @@ -1262,12 +1273,13 @@ func (c *Checker) compareVolumes(ctx context.Context, reader dal.PebbleReader, b // Check). That set is derived from the hash-chain-bound audit // trail — NOT from AppliedProposal.TransientVolumes or // LedgerLog.PurgedVolumes, which are unhashed caches and must - // stay untrusted here. The exclusion key is (account, asset) so a - // multi-asset account whose USD was purged still has its EUR - // compared. Do not "align" this code to consult those proto - // records — it would reintroduce the tampering vector this - // design deliberately removes. - if excluded.contains(vk.LedgerName, vk.Account, vk.Asset) { + // stay untrusted here. The exclusion key is + // (account, asset, color) so a multi-bucket account whose + // (USD, RED) was purged still has its (USD, BLUE) compared. Do + // not "align" this code to consult those proto records — it + // would reintroduce the tampering vector this design + // deliberately removes. + if excluded.contains(vk.LedgerName, vk.Account, vk.Asset, vk.Color) { continue } diff --git a/internal/application/ctrl/controller.go b/internal/application/ctrl/controller.go index 31b38d0dc2..bbbea29d78 100644 --- a/internal/application/ctrl/controller.go +++ b/internal/application/ctrl/controller.go @@ -10,6 +10,14 @@ import ( "github.com/formancehq/ledger/v3/internal/query" ) +// GetAccountOptions configures a GetAccount read. +type GetAccountOptions struct { + // CollapseColors sums every colored bucket of the same asset into a + // single entry with Color = "" in the returned Account.volumes list. + // When false (default), each (asset, color) tuple gets its own entry. + CollapseColors bool +} + //go:generate mockgen -write_source_comment=false -write_package_comment=false -source controller.go -destination controller_generated_test.go -package ctrl . Controller //go:generate mockgen -write_source_comment=false -write_package_comment=false -source controller.go -destination ctrlmock/controller_generated.go -package ctrlmock . Controller type Controller interface { @@ -29,15 +37,17 @@ type Controller interface { // not re-sign it. GetTransaction(ctx context.Context, ledgerName string, transactionID uint64) (*commonpb.Transaction, *string, error) ListTransactions(ctx context.Context, ledgerName string, pageSize uint32, afterTxID uint64, filter *commonpb.QueryFilter, reverse bool) (cursor.Cursor[*commonpb.Transaction], error) - GetAccount(ctx context.Context, ledgerName string, address string) (*commonpb.Account, error) + GetAccount(ctx context.Context, ledgerName string, address string, opts GetAccountOptions) (*commonpb.Account, error) ListAccounts(ctx context.Context, ledgerName string, pageSize uint32, afterAddress string, filter *commonpb.QueryFilter, reverse bool) (cursor.Cursor[*commonpb.Account], error) // Stats operations GetLedgerStats(ctx context.Context, ledgerName string) (*commonpb.LedgerStats, error) // Log operations - // ListLogs returns logs for a specific ledger, ordered by ledger-local log ID. - // Use a LogIdCondition in the filter for pagination. + // ListLogs returns logs for a specific ledger, ordered by ledger-local log + // ID. afterSequence is the ledger-local log ID to start after; the filter + // may add further conditions (e.g. date ranges). Use a LogIdCondition in + // the filter for pagination. ListLogs(ctx context.Context, ledgerName string, afterSequence uint64, pageSize uint32, filter *commonpb.QueryFilter) (cursor.Cursor[*commonpb.Log], error) GetLog(ctx context.Context, sequence uint64) (*commonpb.Log, error) diff --git a/internal/application/ctrl/controller_default.go b/internal/application/ctrl/controller_default.go index 970de7e446..0a0919a09c 100644 --- a/internal/application/ctrl/controller_default.go +++ b/internal/application/ctrl/controller_default.go @@ -547,7 +547,7 @@ func (ctrl *DefaultController) ListAccounts(ctx context.Context, ledgerName stri return cursor.NewClosingCursor(cursor.NewSliceCursor(accounts), handle), nil } -func (ctrl *DefaultController) GetAccount(ctx context.Context, ledgerName string, address string) (*commonpb.Account, error) { +func (ctrl *DefaultController) GetAccount(ctx context.Context, ledgerName string, address string, opts GetAccountOptions) (*commonpb.Account, error) { _, span := tracer.Start(ctx, "ctrl.get_account", trace.WithAttributes( attribute.String("ledger", ledgerName), @@ -573,7 +573,7 @@ func (ctrl *DefaultController) GetAccount(ctx context.Context, ledgerName string return nil, err } - return scanAccount(handle, ctrl.attrs, ledgerInfo.GetName(), address, ctrl.logger) + return scanAccount(handle, ctrl.attrs, ledgerInfo.GetName(), address, opts.CollapseColors, ctrl.logger) } // GetLedgerStats returns aggregate statistics for a ledger. @@ -1784,7 +1784,11 @@ func (ctrl *DefaultController) ListPreparedQueries(ctx context.Context, ledger s func (ctrl *DefaultController) entityEnricher() *query.EntityEnricher { return &query.EntityEnricher{ EnrichAccount: func(reader dal.PebbleReader, ledgerName string, address string) (*commonpb.Account, error) { - return scanAccount(reader, ctrl.attrs, ledgerName, address, ctrl.logger) + // List-style enrichment paths do not surface a per-call collapse + // flag; entries are returned color-segregated and the caller can + // collapse client-side if needed. Per-account GetAccount honors + // the flag through the GetAccountOptions path. + return scanAccount(reader, ctrl.attrs, ledgerName, address, false, ctrl.logger) }, EnrichTransaction: func(ctx context.Context, reader dal.PebbleReader, ledgerName string, txID uint64) (*commonpb.Transaction, error) { return ctrl.buildTransaction(ctx, reader, ledgerName, txID) diff --git a/internal/application/ctrl/controller_generated_test.go b/internal/application/ctrl/controller_generated_test.go index ddc25331c1..aebbc69df8 100644 --- a/internal/application/ctrl/controller_generated_test.go +++ b/internal/application/ctrl/controller_generated_test.go @@ -134,18 +134,18 @@ func (mr *MockControllerMockRecorder) ExecutePreparedQuery(ctx, req any) *gomock } // GetAccount mocks base method. -func (m *MockController) GetAccount(ctx context.Context, ledgerName, address string) (*commonpb.Account, error) { +func (m *MockController) GetAccount(ctx context.Context, ledgerName, address string, opts GetAccountOptions) (*commonpb.Account, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address) + ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address, opts) ret0, _ := ret[0].(*commonpb.Account) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccount indicates an expected call of GetAccount. -func (mr *MockControllerMockRecorder) GetAccount(ctx, ledgerName, address any) *gomock.Call { +func (mr *MockControllerMockRecorder) GetAccount(ctx, ledgerName, address, opts any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockController)(nil).GetAccount), ctx, ledgerName, address) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockController)(nil).GetAccount), ctx, ledgerName, address, opts) } // GetAuditEntry mocks base method. diff --git a/internal/application/ctrl/ctrlmock/controller_generated.go b/internal/application/ctrl/ctrlmock/controller_generated.go index ef6c087c5f..33af9a9496 100644 --- a/internal/application/ctrl/ctrlmock/controller_generated.go +++ b/internal/application/ctrl/ctrlmock/controller_generated.go @@ -11,6 +11,7 @@ import ( context "context" reflect "reflect" + ctrl "github.com/formancehq/ledger/v3/internal/application/ctrl" cursor "github.com/formancehq/ledger/v3/internal/pkg/cursor" auditpb "github.com/formancehq/ledger/v3/internal/proto/auditpb" commonpb "github.com/formancehq/ledger/v3/internal/proto/commonpb" @@ -134,18 +135,18 @@ func (mr *MockControllerMockRecorder) ExecutePreparedQuery(ctx, req any) *gomock } // GetAccount mocks base method. -func (m *MockController) GetAccount(ctx context.Context, ledgerName, address string) (*commonpb.Account, error) { +func (m *MockController) GetAccount(ctx context.Context, ledgerName, address string, opts ctrl.GetAccountOptions) (*commonpb.Account, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address) + ret := m.ctrl.Call(m, "GetAccount", ctx, ledgerName, address, opts) ret0, _ := ret[0].(*commonpb.Account) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccount indicates an expected call of GetAccount. -func (mr *MockControllerMockRecorder) GetAccount(ctx, ledgerName, address any) *gomock.Call { +func (mr *MockControllerMockRecorder) GetAccount(ctx, ledgerName, address, opts any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockController)(nil).GetAccount), ctx, ledgerName, address) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockController)(nil).GetAccount), ctx, ledgerName, address, opts) } // GetAuditEntry mocks base method. diff --git a/internal/application/ctrl/store.go b/internal/application/ctrl/store.go index 3c10acfa50..b4224b82bb 100644 --- a/internal/application/ctrl/store.go +++ b/internal/application/ctrl/store.go @@ -3,6 +3,7 @@ package ctrl import ( "fmt" "math/big" + "sort" logging "github.com/formancehq/go-libs/v5/pkg/observe/log" @@ -14,47 +15,26 @@ import ( ) // assembleAccount builds a commonpb.Account from flushed volume and metadata accumulator entries. +// When collapseColors is true, all colored buckets of the same asset are summed +// into a single entry with Color = "" in the returned Account.volumes list. func assembleAccount( address string, volEntries []attributes.ComputedEntry[*raftcmdpb.VolumePair], metaEntries []attributes.ComputedEntry[*commonpb.MetadataValue], -) *commonpb.Account { + collapseColors bool, +) (*commonpb.Account, error) { account := &commonpb.Account{ Address: address, Metadata: map[string]*commonpb.MetadataValue{}, } if len(volEntries) > 0 { - volumes := make(map[string]*commonpb.VolumesWithBalance, len(volEntries)) - for _, entry := range volEntries { - var vk domain.VolumeKey - - err := vk.Unmarshal(entry.CanonicalKey) - if err != nil { - continue - } - - input := big.NewInt(0) - output := big.NewInt(0) - - if entry.Value != nil { - if entry.Value.GetInput() != nil { - input = entry.Value.GetInput().ToBigInt() - } - - if entry.Value.GetOutput() != nil { - output = entry.Value.GetOutput().ToBigInt() - } - } - - volumes[vk.Asset] = &commonpb.VolumesWithBalance{ - Input: input.String(), - Output: output.String(), - Balance: new(big.Int).Sub(input, output).String(), - } + vols, err := buildAccountVolumes(volEntries, collapseColors) + if err != nil { + return nil, err } - account.Volumes = volumes + account.Volumes = vols } if len(metaEntries) > 0 { @@ -73,7 +53,94 @@ func assembleAccount( } } - return account + return account, nil +} + +// buildAccountVolumes turns the flushed volume entries into the +// `repeated AccountVolume` list carried by Account.volumes. The list is +// sorted by (asset, color) ascending. If collapseColors is true, entries +// with the same asset (different colors) are summed under color = "". +// +// A malformed canonical key surfaces a hard error rather than a silent +// `continue`: every other Pebble scan path in the codebase propagates +// unmarshal errors, and silently dropping a row from GetAccount would +// return a truncated balance the caller has no way to detect (CLAUDE.md +// invariant #7). +func buildAccountVolumes(volEntries []attributes.ComputedEntry[*raftcmdpb.VolumePair], collapseColors bool) ([]*commonpb.AccountVolume, error) { + type key struct { + asset string + color string + } + + // running holds the accumulating sums as *big.Int so we never round-trip + // through decimal strings per row; the final AccountVolume (formatted once) + // is built at the end. + type running struct { + asset string + color string + input *big.Int + output *big.Int + } + + totals := make(map[key]*running, len(volEntries)) + + for _, entry := range volEntries { + var vk domain.VolumeKey + if err := vk.Unmarshal(entry.CanonicalKey); err != nil { + return nil, fmt.Errorf("malformed volume canonical key in account scan: %w", err) + } + + input := big.NewInt(0) + output := big.NewInt(0) + if entry.Value != nil { + if entry.Value.GetInput() != nil { + input = entry.Value.GetInput().ToBigInt() + } + if entry.Value.GetOutput() != nil { + output = entry.Value.GetOutput().ToBigInt() + } + } + + bucketColor := vk.Color + if collapseColors { + bucketColor = "" + } + + k := key{asset: vk.Asset, color: bucketColor} + acc, ok := totals[k] + if !ok { + acc = &running{ + asset: vk.Asset, + color: bucketColor, + input: big.NewInt(0), + output: big.NewInt(0), + } + totals[k] = acc + } + + acc.input.Add(acc.input, input) + acc.output.Add(acc.output, output) + } + + out := make([]*commonpb.AccountVolume, 0, len(totals)) + for _, v := range totals { + // Format the accumulated sums once, and compute the balance from the + // *big.Int totals (after any color collapse). + out = append(out, &commonpb.AccountVolume{ + Asset: v.asset, + Color: v.color, + Volumes: &commonpb.VolumesWithBalance{ + Input: v.input.String(), + Output: v.output.String(), + Balance: new(big.Int).Sub(v.input, v.output).String(), + }, + }) + } + sort.Slice(out, func(i, j int) bool { + return commonpb.LessByAssetColor(out[i], out[j]) + }) + + return out, nil } // scanAccount performs two forward scans — one for Volume and one for Metadata — @@ -87,6 +154,7 @@ func scanAccount( attrs *attributes.Attributes, ledgerName string, address string, + collapseColors bool, diagLogger ...logging.Logger, ) (*commonpb.Account, error) { var logger logging.Logger @@ -126,5 +194,5 @@ func scanAccount( }).Infof("scanAccount complete") } - return assembleAccount(address, volEntries, metaEntries), nil + return assembleAccount(address, volEntries, metaEntries, collapseColors) } diff --git a/internal/application/ctrl/store_color_test.go b/internal/application/ctrl/store_color_test.go new file mode 100644 index 0000000000..e617c136e6 --- /dev/null +++ b/internal/application/ctrl/store_color_test.go @@ -0,0 +1,126 @@ +package ctrl + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/formancehq/ledger/v3/internal/domain" + "github.com/formancehq/ledger/v3/internal/infra/attributes" + "github.com/formancehq/ledger/v3/internal/proto/commonpb" + "github.com/formancehq/ledger/v3/internal/proto/raftcmdpb" +) + +// volEntry is a small helper to build an attributes.ComputedEntry against +// a (account, asset, color) tuple with concrete input/output amounts. +func volEntry(t *testing.T, ledgerName string, account, asset, color string, in, out int64) attributes.ComputedEntry[*raftcmdpb.VolumePair] { + t.Helper() + vk := domain.NewVolumeKey(ledgerName, account, asset, color) + + return attributes.ComputedEntry[*raftcmdpb.VolumePair]{ + CanonicalKey: vk.Bytes(), + Value: &raftcmdpb.VolumePair{ + Input: commonpb.NewUint256FromUint64(uint64(in)), + Output: commonpb.NewUint256FromUint64(uint64(out)), + }, + } +} + +// Default mode (collapseColors=false) keeps every (asset, color) bucket as +// its own entry, sorted by (asset, color) ascending. +func TestAssembleAccount_SegregatesColorsByDefault(t *testing.T) { + t.Parallel() + + entries := []attributes.ComputedEntry[*raftcmdpb.VolumePair]{ + volEntry(t, "test", "alice", "USD/2", "OPS", 25, 0), + volEntry(t, "test", "alice", "USD/2", "", 100, 0), + volEntry(t, "test", "alice", "USD/2", "GRANTS", 50, 0), + volEntry(t, "test", "alice", "EUR/2", "", 10, 0), + } + + acct, err := assembleAccount("alice", entries, nil, false) + require.NoError(t, err) + + // Order: (EUR/2,""), (USD/2,""), (USD/2,"GRANTS"), (USD/2,"OPS") + got := acct.GetVolumes() + require.Len(t, got, 4) + + require.Equal(t, "EUR/2", got[0].GetAsset()) + require.Equal(t, "", got[0].GetColor()) + + require.Equal(t, "USD/2", got[1].GetAsset()) + require.Equal(t, "", got[1].GetColor()) + require.Equal(t, "100", got[1].GetVolumes().GetInput()) + require.Equal(t, "100", got[1].GetVolumes().GetBalance()) + + require.Equal(t, "USD/2", got[2].GetAsset()) + require.Equal(t, "GRANTS", got[2].GetColor()) + require.Equal(t, "50", got[2].GetVolumes().GetBalance()) + + require.Equal(t, "USD/2", got[3].GetAsset()) + require.Equal(t, "OPS", got[3].GetColor()) + require.Equal(t, "25", got[3].GetVolumes().GetBalance()) +} + +// Collapse mode sums every (asset, *) bucket into a single entry with +// color = "" and amounts summed. +func TestAssembleAccount_CollapseColors(t *testing.T) { + t.Parallel() + + entries := []attributes.ComputedEntry[*raftcmdpb.VolumePair]{ + volEntry(t, "test", "alice", "USD/2", "", 100, 0), + volEntry(t, "test", "alice", "USD/2", "GRANTS", 50, 10), + volEntry(t, "test", "alice", "USD/2", "OPS", 25, 5), + } + + acct, err := assembleAccount("alice", entries, nil, true) + require.NoError(t, err) + + require.Len(t, acct.GetVolumes(), 1) + entry := acct.GetVolumes()[0] + require.Equal(t, "USD/2", entry.GetAsset()) + require.Equal(t, "", entry.GetColor(), "collapsed entries are produced under the empty color") + require.Equal(t, "175", entry.GetVolumes().GetInput()) // 100 + 50 + 25 + require.Equal(t, "15", entry.GetVolumes().GetOutput()) // 0 + 10 + 5 + require.Equal(t, "160", entry.GetVolumes().GetBalance()) // 175 - 15 +} + +// FindVolume helper round-trip: drilling into the returned Account by +// (asset, color) must return the correct entry both colored and uncolored. +func TestAssembleAccount_FindVolume(t *testing.T) { + t.Parallel() + + entries := []attributes.ComputedEntry[*raftcmdpb.VolumePair]{ + volEntry(t, "test", "alice", "USD/2", "", 100, 0), + volEntry(t, "test", "alice", "USD/2", "GRANTS", 50, 0), + } + acct, err := assembleAccount("alice", entries, nil, false) + require.NoError(t, err) + + require.Equal(t, "100", acct.FindVolume("USD/2", "").GetBalance()) + require.Equal(t, "50", acct.FindVolume("USD/2", "GRANTS").GetBalance()) + require.Nil(t, acct.FindVolume("USD/2", "MISSING")) + require.Nil(t, acct.FindVolume("EUR/2", "")) +} + +// TestAssembleAccount_MalformedKeyReturnsError pins the contract that a +// malformed canonical volume key surfaces a hard error rather than being +// silently dropped from the GetAccount output. Silent skip would return a +// truncated balance the caller cannot detect — CLAUDE.md invariant #7. +func TestAssembleAccount_MalformedKeyReturnsError(t *testing.T) { + t.Parallel() + + entries := []attributes.ComputedEntry[*raftcmdpb.VolumePair]{ + { + // A byte slice too short to decode as a VolumeKey (the canonical + // shape begins with a 64-byte padded ledger name) — VolumeKey.Unmarshal + // rejects it. + CanonicalKey: []byte{0xAB, 0xCD}, + Value: &raftcmdpb.VolumePair{}, + }, + } + + _, err := assembleAccount("alice", entries, nil, false) + require.Error(t, err, "malformed canonical key must surface as an error, not be silently skipped") + require.Contains(t, err.Error(), "malformed volume canonical key") +} diff --git a/internal/application/events/clickhouse_data_test.go b/internal/application/events/clickhouse_data_test.go index e5863b2198..b7638aa68f 100644 --- a/internal/application/events/clickhouse_data_test.go +++ b/internal/application/events/clickhouse_data_test.go @@ -682,6 +682,66 @@ func TestSinkConvertTransaction_Nil(t *testing.T) { require.Nil(t, result) } +// TestSinkConvertTransaction_PreservesColor pins that the color dimension +// reaches every analytical sink (ClickHouse/Databricks/Kafka/NATS share this +// converter). Without this guarantee, downstream warehouses cannot reconstruct +// the segregated balance history from emitted events. +func TestSinkConvertTransaction_PreservesColor(t *testing.T) { + t.Parallel() + + tx := &commonpb.Transaction{ + Id: 1, + Postings: []*commonpb.Posting{ + { + Source: "world", + Destination: "alice", + Asset: "USD/2", + Color: "GRANTS", + Amount: commonpb.NewUint256FromUint64(100), + }, + }, + Timestamp: &commonpb.Timestamp{Data: 1700000000}, + InsertedAt: &commonpb.Timestamp{Data: 1700000000}, + } + + result := sinkConvertTransaction(tx) + require.NotNil(t, result) + require.Len(t, result.Postings, 1) + require.Equal(t, "GRANTS", result.Postings[0].Color, + "color must flow through to the analytical-sink payload") +} + +// TestSinkPosting_AlwaysEmitsColor pins the analytical-sink JSON contract: +// the uncolored bucket must serialize as `color:""` (not be omitted), so +// downstream warehouses can distinguish a NULL color from a pre-color schema +// row. Mirrors the contract enforced by commonpb.Posting.MarshalJSON. +func TestSinkPosting_AlwaysEmitsColor(t *testing.T) { + t.Parallel() + + tx := &commonpb.Transaction{ + Id: 1, + Postings: []*commonpb.Posting{ + { + Source: "world", + Destination: "alice", + Asset: "USD/2", + Amount: commonpb.NewUint256FromUint64(100), + // Color intentionally left empty — uncolored bucket. + }, + }, + Timestamp: &commonpb.Timestamp{Data: 1700000000}, + InsertedAt: &commonpb.Timestamp{Data: 1700000000}, + } + + sink := sinkConvertTransaction(tx) + require.NotNil(t, sink) + + data, err := json.Marshal(sink.Postings[0]) + require.NoError(t, err) + require.Contains(t, string(data), `"color":""`, + "uncolored postings must surface color:\"\" in sink JSON, not be omitted") +} + func TestClickHouseCreateTableDDL(t *testing.T) { t.Parallel() @@ -689,6 +749,10 @@ func TestClickHouseCreateTableDDL(t *testing.T) { require.Contains(t, ddl, "CREATE TABLE IF NOT EXISTS test_events") require.Contains(t, ddl, "log_sequence UInt64") require.Contains(t, ddl, "ReplacingMergeTree()") + // color is part of the posting sub-schema so warehouse queries can + // reconstruct the segregated buckets — see sinkPosting.MarshalJSON. + require.Contains(t, ddl, "color String", + "ClickHouse posting columns must include color to match the sink JSON shape") } func TestSinkTime_MarshalJSON(t *testing.T) { diff --git a/internal/application/events/sink_clickhouse.go b/internal/application/events/sink_clickhouse.go index 36e4db938f..8a9f9f70fd 100644 --- a/internal/application/events/sink_clickhouse.go +++ b/internal/application/events/sink_clickhouse.go @@ -36,7 +36,8 @@ const clickhouseTransactionColumns = `JSON( source String, destination String, amount UInt256, - asset String + asset String, + color String )), metadata Map(String, String), reference Nullable(String), diff --git a/internal/application/events/sink_data_common.go b/internal/application/events/sink_data_common.go index 628f1e13c9..3fc2157e53 100644 --- a/internal/application/events/sink_data_common.go +++ b/internal/application/events/sink_data_common.go @@ -75,6 +75,11 @@ type sinkPosting struct { Destination string `json:"destination"` Amount *big.Int `json:"amount"` Asset string `json:"asset"` + // Color is always emitted (no `omitempty`) so downstream analytical + // consumers can distinguish the uncolored bucket (`color:""`) from an + // older payload that predates the dimension — same contract as + // commonpb.Posting.MarshalJSON, VolumeEntry, accountVolumeJSON. + Color string `json:"color"` } // ---------- Conversion from Event protobuf ---------- @@ -192,6 +197,7 @@ func sinkConvertTransaction(tx *commonpb.Transaction) *sinkTransaction { Source: p.GetSource(), Destination: p.GetDestination(), Asset: p.GetAsset(), + Color: p.GetColor(), Amount: p.GetAmount().ToBigInt(), } } diff --git a/internal/application/indexbuilder/backfill_postings.go b/internal/application/indexbuilder/backfill_postings.go index 75a89a7b8d..03e3f4abb7 100644 --- a/internal/application/indexbuilder/backfill_postings.go +++ b/internal/application/indexbuilder/backfill_postings.go @@ -171,7 +171,7 @@ func (b *Builder) processBackfillPostings(ctx context.Context, stop <-chan struc for i := range parsed.Postings { p := &parsed.Postings[i] if err := b.indexPostingAddressMappings( - kb, cfg, parsed.Ledger, parsed.TxID, p.Source, p.Destination, p.Asset, + kb, cfg, parsed.Ledger, parsed.TxID, p.Source, p.Destination, p.Asset, p.Color, indexAny, indexSource, indexDestination, excludedVolumes, ); err != nil { _ = batch.Cancel() diff --git a/internal/application/indexbuilder/backfill_test.go b/internal/application/indexbuilder/backfill_test.go index d508c1db37..cecc335839 100644 --- a/internal/application/indexbuilder/backfill_test.go +++ b/internal/application/indexbuilder/backfill_test.go @@ -293,19 +293,19 @@ func TestIndexPostingAddressMappingsSkipsExcludedAccounts(t *testing.T) { } require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 42, "transient:source", "kept:dest", "USD", + b.kb, cfg, "test", 42, "transient:source", "kept:dest", "USD", "", true, true, true, excludedVolumes, )) require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 43, "kept:source", "purged:dest", "USD", + b.kb, cfg, "test", 43, "kept:source", "purged:dest", "USD", "", true, true, true, excludedVolumes, )) require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 44, "shared:account", "kept:dest", "USD", + b.kb, cfg, "test", 44, "shared:account", "kept:dest", "USD", "", true, true, true, excludedVolumes, )) require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 45, "shared:account", "kept:dest", "EUR", + b.kb, cfg, "test", 45, "shared:account", "kept:dest", "EUR", "", true, true, true, excludedVolumes, )) require.NoError(t, b.wb.Flush()) @@ -408,7 +408,7 @@ func TestIndexPostingAddressMappingsWritesAccountByAsset(t *testing.T) { // source=accounts:alice destination=accounts:bob asset="USD/2". require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.wb.Flush()) @@ -441,11 +441,11 @@ func TestIndexPostingAddressMappingsAccountByAssetDedup(t *testing.T) { // Feed the same posting twice within the same batch. require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 2, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 2, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.wb.Flush()) @@ -488,7 +488,7 @@ func TestIndexPostingAddressMappingsAccountByAssetSurvivesInBatchDelete(t *testi batch1 := store.NewBatch() b.initBatch(batch1) require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.wb.Flush()) @@ -502,7 +502,7 @@ func TestIndexPostingAddressMappingsAccountByAssetSurvivesInBatchDelete(t *testi require.NoError(t, readstore.DeleteLedgerIndexes(b.wb.Batch(), "test")) b.markLedgerDeletedInBatch("test") require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 2, "accounts:alice", "accounts:carol", "USD/2", + b.kb, cfg, "test", 2, "accounts:alice", "accounts:carol", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.wb.Flush()) @@ -537,7 +537,7 @@ func TestIndexPostingAddressMappingsAccountByAssetSurvivesInBatchDelete(t *testi // Old generation writes (queued, uncommitted) populate seenAcctAsset ... require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, nil, )) // ... the ledger is deleted in the same batch (range delete queued, @@ -547,7 +547,7 @@ func TestIndexPostingAddressMappingsAccountByAssetSurvivesInBatchDelete(t *testi // ... and the recreated ledger re-touches alice. Without clearing // seenAcctAsset on delete, this Put would be skipped and lost. require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 2, "accounts:alice", "accounts:carol", "USD/2", + b.kb, cfg, "test", 2, "accounts:alice", "accounts:carol", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.wb.Flush()) @@ -584,7 +584,7 @@ func TestIndexPostingAddressMappingsAccountByAssetExcludesTransient(t *testing.T } require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, excludedVolumes, )) require.NoError(t, b.wb.Flush()) @@ -616,7 +616,7 @@ func TestIndexPostingAddressMappingsAccountByAssetDisabled(t *testing.T) { cfg := newLedgerIndexConfig() require.NoError(t, b.indexPostingAddressMappings( - b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", + b.kb, cfg, "test", 1, "accounts:alice", "accounts:bob", "USD/2", "", false, false, false, nil, )) require.NoError(t, b.wb.Flush()) diff --git a/internal/application/indexbuilder/process_logs.go b/internal/application/indexbuilder/process_logs.go index fbc9b8f991..b7550a7053 100644 --- a/internal/application/indexbuilder/process_logs.go +++ b/internal/application/indexbuilder/process_logs.go @@ -600,7 +600,7 @@ func (b *Builder) indexCreatedTransaction( b.accounts[posting.GetDestination()] = struct{}{} if err := b.indexPostingAddressMappings( - kb, cfg, ledger, txn.GetId(), posting.GetSource(), posting.GetDestination(), posting.GetAsset(), + kb, cfg, ledger, txn.GetId(), posting.GetSource(), posting.GetDestination(), posting.GetAsset(), posting.GetColor(), indexAny, indexSource, indexDestination, excludedVolumes, ); err != nil { return err @@ -726,7 +726,7 @@ func (b *Builder) indexRevertedTransaction( b.accounts[posting.GetDestination()] = struct{}{} if err := b.indexPostingAddressMappings( - kb, cfg, ledger, revertTxn.GetId(), posting.GetSource(), posting.GetDestination(), posting.GetAsset(), + kb, cfg, ledger, revertTxn.GetId(), posting.GetSource(), posting.GetDestination(), posting.GetAsset(), posting.GetColor(), indexAny, indexSource, indexDestination, excludedVolumes, ); err != nil { return err @@ -800,14 +800,15 @@ func (b *Builder) indexPostingAddressMappings( source string, destination string, asset string, + color string, indexAny bool, indexSource bool, indexDestination bool, excludedVolumes map[domain.AccountAssetKey]struct{}, ) error { wb := b.wb - sourceExcluded := isExcluded(excludedVolumes, source, asset) - destinationExcluded := isExcluded(excludedVolumes, destination, asset) + sourceExcluded := isExcluded(excludedVolumes, source, asset, color) + destinationExcluded := isExcluded(excludedVolumes, destination, asset, color) // Account has-asset index: record every (account, assetBase, precision) a // posting touches, for both source and destination, skipping excluded @@ -1235,16 +1236,17 @@ func extractMetadataKeyFromReverseMap(key, nsPrefix []byte, ns string) string { return "" } -// isExcluded returns true if the (account, asset) tuple is in the excluded -// set (transient or purged ephemeral). Both dimensions matter — a multi-asset -// account may have one asset purged while another stays kept, and we must -// not over-skip mappings for the kept asset. -func isExcluded(excluded map[domain.AccountAssetKey]struct{}, account, asset string) bool { +// isExcluded returns true if the (account, asset, color) tuple is in the +// excluded set (transient or purged ephemeral). All three dimensions matter +// — a multi-bucket account may have one (asset, color) purged while another +// color of the same asset stays kept, and we must not over-skip mappings for +// the kept bucket. +func isExcluded(excluded map[domain.AccountAssetKey]struct{}, account, asset, color string) bool { if excluded == nil { return false } - _, ok := excluded[domain.AccountAssetKey{Account: account, Asset: asset}] + _, ok := excluded[domain.AccountAssetKey{Account: account, Asset: asset, Color: color}] return ok } diff --git a/internal/application/indexbuilder/protowire_postings.go b/internal/application/indexbuilder/protowire_postings.go index f93759bb8d..c02c919aa5 100644 --- a/internal/application/indexbuilder/protowire_postings.go +++ b/internal/application/indexbuilder/protowire_postings.go @@ -9,13 +9,16 @@ import ( "github.com/formancehq/ledger/v3/internal/proto/commonpb" ) -// rawPosting holds the source, destination and asset extracted from a -// Posting message. The asset is kept so per-asset exclusion lookups against -// purged/transient volume sets stay precise inside multi-asset accounts. +// rawPosting holds the source, destination, asset, and color extracted from +// a Posting message. Asset and color are both kept so per-bucket exclusion +// lookups against purged/transient volume sets stay precise: two color +// buckets of the same (account, asset) can have different purge fates and +// must not be collapsed. type rawPosting struct { Source string Destination string Asset string + Color string } // parsedLog holds the fields extracted by the protowire fast path. @@ -307,12 +310,12 @@ func parseTransaction(data []byte, postings []rawPosting) (txID uint64, result [ return 0, result, errors.New("protowire: invalid bytes for Posting") } - src, dst, asset, perr := parsePosting(b) + src, dst, asset, color, perr := parsePosting(b) if perr != nil { return 0, result, perr } - result = append(result, rawPosting{Source: src, Destination: dst, Asset: asset}) + result = append(result, rawPosting{Source: src, Destination: dst, Asset: asset, Color: color}) data = data[bn:] case num == 5 && typ == protowire.Fixed64Type: v, vn := protowire.ConsumeFixed64(data) @@ -335,8 +338,11 @@ func parseTransaction(data []byte, postings []rawPosting) (txID uint64, result [ return txID, result, nil } -// parseTouchedVolume extracts account (field 1) and asset (field 2) from a -// commonpb.TouchedVolume sub-message embedded in LedgerLog.purged_volumes. +// parseTouchedVolume extracts account (field 1), asset (field 2), and color +// (field 3) from a commonpb.TouchedVolume sub-message embedded in +// LedgerLog.purged_volumes. Color is part of the volume identity so +// indexer exclusions don't over-collapse colored buckets sharing +// (account, asset). func parseTouchedVolume(data []byte) (*commonpb.TouchedVolume, error) { out := &commonpb.TouchedVolume{} for len(data) > 0 { @@ -364,6 +370,14 @@ func parseTouchedVolume(data []byte) (*commonpb.TouchedVolume, error) { out.Asset = string(b) data = data[bn:] + case num == 3 && typ == protowire.BytesType: + b, bn := protowire.ConsumeBytes(data) + if bn < 0 { + return nil, errors.New("protowire: invalid bytes for TouchedVolume.color") + } + + out.Color = string(b) + data = data[bn:] default: n := protowire.ConsumeFieldValue(num, typ, data) if n < 0 { @@ -377,12 +391,14 @@ func parseTouchedVolume(data []byte) (*commonpb.TouchedVolume, error) { return out, nil } -// parsePosting extracts source, destination and asset from Posting bytes. -func parsePosting(data []byte) (source, destination, asset string, err error) { +// parsePosting extracts source, destination, asset, and color from Posting +// bytes. Color (field 5) is part of the volume identity so per-bucket +// exclusion lookups can distinguish colored buckets sharing (account, asset). +func parsePosting(data []byte) (source, destination, asset, color string, err error) { for len(data) > 0 { num, typ, n := protowire.ConsumeTag(data) if n < 0 { - return "", "", "", errors.New("protowire: invalid tag in Posting") + return "", "", "", "", errors.New("protowire: invalid tag in Posting") } data = data[n:] @@ -391,7 +407,7 @@ func parsePosting(data []byte) (source, destination, asset string, err error) { case num == 1 && typ == protowire.BytesType: b, bn := protowire.ConsumeBytes(data) if bn < 0 { - return "", "", "", errors.New("protowire: invalid bytes for Posting.source") + return "", "", "", "", errors.New("protowire: invalid bytes for Posting.source") } source = string(b) @@ -399,7 +415,7 @@ func parsePosting(data []byte) (source, destination, asset string, err error) { case num == 2 && typ == protowire.BytesType: b, bn := protowire.ConsumeBytes(data) if bn < 0 { - return "", "", "", errors.New("protowire: invalid bytes for Posting.destination") + return "", "", "", "", errors.New("protowire: invalid bytes for Posting.destination") } destination = string(b) @@ -407,22 +423,30 @@ func parsePosting(data []byte) (source, destination, asset string, err error) { case num == 4 && typ == protowire.BytesType: b, bn := protowire.ConsumeBytes(data) if bn < 0 { - return "", "", "", errors.New("protowire: invalid bytes for Posting.asset") + return "", "", "", "", errors.New("protowire: invalid bytes for Posting.asset") } asset = string(b) data = data[bn:] + case num == 5 && typ == protowire.BytesType: + b, bn := protowire.ConsumeBytes(data) + if bn < 0 { + return "", "", "", "", errors.New("protowire: invalid bytes for Posting.color") + } + + color = string(b) + data = data[bn:] default: n := protowire.ConsumeFieldValue(num, typ, data) if n < 0 { - return "", "", "", errors.New("protowire: invalid field in Posting") + return "", "", "", "", errors.New("protowire: invalid field in Posting") } data = data[n:] } } - return source, destination, asset, nil + return source, destination, asset, color, nil } // scanBytesField scans protobuf fields looking for a length-delimited field diff --git a/internal/bootstrap/config_validation.go b/internal/bootstrap/config_validation.go index b8edd4c3c8..15471d4d2b 100644 --- a/internal/bootstrap/config_validation.go +++ b/internal/bootstrap/config_validation.go @@ -14,7 +14,13 @@ import ( // CurrentStorageSchemaVersion is the storage schema version that this binary // expects. Increment this when the Pebble key layout or value encoding changes // in a way that is not backward-compatible. -const CurrentStorageSchemaVersion uint32 = 1 +// +// v2: color-of-money segregation. VolumeKey canonical bytes gained a +// [color]\x00 segment between account and asset (see domain.VolumeKey), so a +// schema-v1 store's volume keys no longer parse under the v2 layout. Refusing +// to open a v1 store (via SchemaVersionError, non-bypassable) turns what would +// otherwise be silent balance corruption into a fatal, actionable boot error. +const CurrentStorageSchemaVersion uint32 = 2 // SchemaVersionError is returned when the persisted storage schema version is // incompatible with the running binary. This is NOT bypassable with diff --git a/internal/bootstrap/config_validation_test.go b/internal/bootstrap/config_validation_test.go index 6e5e041778..40f20c38b2 100644 --- a/internal/bootstrap/config_validation_test.go +++ b/internal/bootstrap/config_validation_test.go @@ -161,12 +161,33 @@ func TestValidateOrPersistConfig_SchemaVersionBackfill(t *testing.T) { })) require.NoError(t, batch.Commit()) - // Boot with current code should backfill to version 1 and succeed. cfg := Config{ RaftConfig: node.NodeConfig{NodeID: 1}, ClusterID: "test", } err := ValidateOrPersistConfig(store, cfg, logger, false) + + if CurrentStorageSchemaVersion > 1 { + // A pre-versioning store is a v1 store. Once the binary requires a + // newer schema, that store must be rejected as too-old (never + // silently upgraded) — but the version-0 → version-1 backfill must + // still have been persisted first. + require.Error(t, err) + + var schemaErr *SchemaVersionError + require.ErrorAs(t, err, &schemaErr) + require.False(t, schemaErr.Downgrade) + require.Equal(t, uint32(1), schemaErr.Persisted) + require.Equal(t, CurrentStorageSchemaVersion, schemaErr.Current) + + persisted, err := LoadPersistedConfig(store) + require.NoError(t, err) + require.Equal(t, uint32(1), persisted.GetStorageSchemaVersion()) + + return + } + + // Version 1 == current: backfill to version 1 and succeed. require.NoError(t, err) persisted, err := LoadPersistedConfig(store) diff --git a/internal/bootstrap/controller_routed.go b/internal/bootstrap/controller_routed.go index ffcadb6c9d..bb8cf8454f 100644 --- a/internal/bootstrap/controller_routed.go +++ b/internal/bootstrap/controller_routed.go @@ -230,7 +230,7 @@ func (b *RoutedController) GetAuditEntry(ctx context.Context, sequence uint64) ( return c.GetAuditEntry(ctx, sequence) } -func (b *RoutedController) GetAccount(ctx context.Context, ledgerName string, address string) (*commonpb.Account, error) { +func (b *RoutedController) GetAccount(ctx context.Context, ledgerName string, address string, opts ctrl.GetAccountOptions) (*commonpb.Account, error) { c, barrier, err := b.readCtrl(ctx) if err != nil { return nil, err @@ -248,7 +248,7 @@ func (b *RoutedController) GetAccount(ctx context.Context, ledgerName string, ad }).Infof("read barrier for GetAccount") } - return c.GetAccount(ctx, ledgerName, address) + return c.GetAccount(ctx, ledgerName, address, opts) } func (b *RoutedController) ListAccounts(ctx context.Context, ledgerName string, pageSize uint32, afterAddress string, filter *commonpb.QueryFilter, reverse bool) (cursor.Cursor[*commonpb.Account], error) { diff --git a/internal/domain/analysis/compact.go b/internal/domain/analysis/compact.go index 6e27a9c37e..14178930f7 100644 --- a/internal/domain/analysis/compact.go +++ b/internal/domain/analysis/compact.go @@ -15,10 +15,14 @@ type CompactAccount struct { } // CompactPosting holds only the fields needed for transaction analysis. +// Color is part of the posting identity for analysis purposes: two flows +// that share (source, destination, asset) but differ in color are two +// different segregated buckets and must produce distinct signatures. type CompactPosting struct { Source string Destination string Asset string + Color string Amount *big.Int } @@ -39,6 +43,7 @@ func ExtractCompactTransaction(tx *commonpb.Transaction) CompactTransaction { Source: p.GetSource(), Destination: p.GetDestination(), Asset: p.GetAsset(), + Color: p.GetColor(), Amount: p.GetAmount().ToBigInt(), } } diff --git a/internal/domain/analysis/flow_discovery.go b/internal/domain/analysis/flow_discovery.go index b2b1b52c55..a99f13f241 100644 --- a/internal/domain/analysis/flow_discovery.go +++ b/internal/domain/analysis/flow_discovery.go @@ -17,10 +17,16 @@ import ( // the same flow signature. It uses online accumulators instead of storing // the full transaction list, keeping memory at O(unique signatures). type txGroupAccum struct { + // signature is the collision-safe internal grouping key (NUL-delimited). + // It is never surfaced on the wire — see displaySignature. signature string - postings []*servicepb.NormalizedPosting - structure servicepb.PostingStructure - count uint64 + // displaySignature is the human-readable form published on + // FlowPattern.signature (e.g. "world -> bank:main [USD]" or, colored, + // "world -> bank:main [USD/RED]"). + displaySignature string + postings []*servicepb.NormalizedPosting + structure servicepb.PostingStructure + count uint64 // Temporal accumulators firstSeen, lastSeen uint64 hasSeen bool @@ -94,7 +100,7 @@ func (g *txGroupAccum) addTransaction(ct CompactTransaction) { // toFlowPattern materializes the accumulated statistics into a FlowPattern. func (g *txGroupAccum) toFlowPattern() *servicepb.FlowPattern { pattern := &servicepb.FlowPattern{ - Signature: g.signature, + Signature: g.displaySignature, Structure: g.structure, TransactionCount: g.count, Postings: g.postings, @@ -247,10 +253,11 @@ func AnalyzeTransactionsFromIterators( g, ok := groups[sig] if !ok { g = &txGroupAccum{ - signature: sig, - postings: normalized, - structure: classifyPostingStructure(normalized), - volumes: make(map[string]*assetAccum), + signature: sig, + displaySignature: computeFlowDisplaySignature(normalized), + postings: normalized, + structure: classifyPostingStructure(normalized), + volumes: make(map[string]*assetAccum), } groups[sig] = g } @@ -351,24 +358,74 @@ func normalizePostings(postings []CompactPosting, root *trieNode, addrCache map[ SourcePattern: cachedNormalizeAddress(postings[i].Source, root, addrCache), DestinationPattern: cachedNormalizeAddress(postings[i].Destination, root, addrCache), Asset: postings[i].Asset, + Color: postings[i].Color, } } return normalized } -// computeFlowSignature returns a canonical, sorted signature string from normalized postings. +// flowSignaturePart formats a single posting into its signature contribution. +// Color is always included so two postings differing only by color produce +// distinct signatures (and therefore distinct flow stats). +// +// Components are joined with NUL bytes: ValidateAccountAddress / ValidateAsset +// / ValidateColor all reject NUL upstream, so the separator cannot appear +// inside any component. This keeps the signature unambiguous even if a +// component contains characters previously used as separators (`->`, `[`, +// `|`, `]`). +func flowSignaturePart(p *servicepb.NormalizedPosting) string { + return p.GetSourcePattern() + "\x00" + p.GetDestinationPattern() + "\x00" + p.GetAsset() + "\x00" + p.GetColor() +} + +// computeFlowSignature returns the canonical, sorted internal grouping key from +// normalized postings. It is NUL-delimited and NOT human-readable — it is used +// only as the groups map key and the deterministic sort tiebreaker, never as a +// wire value. The public, human-readable signature is computeFlowDisplaySignature. func computeFlowSignature(postings []*servicepb.NormalizedPosting) string { if len(postings) == 1 { // Fast path: single posting, no sorting needed. - p := postings[0] + return flowSignaturePart(postings[0]) + } + + parts := make([]string, len(postings)) + for i, p := range postings { + parts[i] = flowSignaturePart(p) + } + + sort.Strings(parts) - return p.GetSourcePattern() + "->" + p.GetDestinationPattern() + "[" + p.GetAsset() + "]" + return strings.Join(parts, ";") +} + +// flowDisplayPart formats a single posting as the human-readable +// "source->destination[asset]" fragment. This is byte-for-byte the legacy +// (pre-color) format — no spaces around the arrow or brackets — so uncolored +// flows are unchanged from release/v3.0. Colored postings extend the form by +// appending "/color" inside the brackets ("source->destination[asset/color]"); +// the uncolored bucket (empty color) keeps the bare "[asset]". Unlike +// flowSignaturePart this is NOT collision-safe and must never be a grouping key. +func flowDisplayPart(p *servicepb.NormalizedPosting) string { + asset := p.GetAsset() + if color := p.GetColor(); color != "" { + asset += "/" + color + } + + return p.GetSourcePattern() + "->" + p.GetDestinationPattern() + "[" + asset + "]" +} + +// computeFlowDisplaySignature builds the human-readable FlowPattern.signature +// from normalized postings. Multi-posting flows join their fragments with ";" +// (the legacy separator) in the same sorted order as the internal grouping key, +// so two flows with the same shape produce the same display string. +func computeFlowDisplaySignature(postings []*servicepb.NormalizedPosting) string { + if len(postings) == 1 { + return flowDisplayPart(postings[0]) } parts := make([]string, len(postings)) for i, p := range postings { - parts[i] = p.GetSourcePattern() + "->" + p.GetDestinationPattern() + "[" + p.GetAsset() + "]" + parts[i] = flowDisplayPart(p) } sort.Strings(parts) diff --git a/internal/domain/analysis/flow_discovery_test.go b/internal/domain/analysis/flow_discovery_test.go index d0fe98c4e2..689b46ea31 100644 --- a/internal/domain/analysis/flow_discovery_test.go +++ b/internal/domain/analysis/flow_discovery_test.go @@ -296,3 +296,105 @@ func TestAnalyzeTransactions_GroupedBySignature(t *testing.T) { assert.Equal(t, uint64(2), resp.GetFlowPatterns()[0].GetTransactionCount()) assert.Equal(t, uint64(1), resp.GetFlowPatterns()[1].GetTransactionCount()) } + +// TestFlowSignaturePart_NoCollisionWithSeparatorsInComponents guards the +// signature format against component-vs-separator confusion. Even if the +// upstream validators ever loosened to allow `->`, `[`, `|`, or `]` inside +// account / asset / color components, the NUL-byte separator keeps the +// signature unambiguous. +func TestFlowSignaturePart_NoCollisionWithSeparatorsInComponents(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + a, b *servicepb.NormalizedPosting + }{ + { + name: "arrow-in-account vs literal arrow", + a: &servicepb.NormalizedPosting{SourcePattern: "src->x", DestinationPattern: "dst", Asset: "USD", Color: ""}, + b: &servicepb.NormalizedPosting{SourcePattern: "src", DestinationPattern: "x->dst", Asset: "USD", Color: ""}, + }, + { + name: "bracket-in-color vs literal", + a: &servicepb.NormalizedPosting{SourcePattern: "src", DestinationPattern: "dst", Asset: "USD", Color: "A]B"}, + b: &servicepb.NormalizedPosting{SourcePattern: "src", DestinationPattern: "dst", Asset: "USD]A", Color: "B"}, + }, + { + name: "pipe in components", + a: &servicepb.NormalizedPosting{SourcePattern: "src", DestinationPattern: "dst", Asset: "USD|X", Color: ""}, + b: &servicepb.NormalizedPosting{SourcePattern: "src", DestinationPattern: "dst", Asset: "USD", Color: "X"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.NotEqual(t, flowSignaturePart(tc.a), flowSignaturePart(tc.b), + "signatures must not collide when separators appear inside components") + }) + } +} + +// TestComputeFlowDisplaySignature pins the human-readable public signature: the +// internal NUL-delimited grouping key must never leak onto FlowPattern.signature. +// Uncolored flows are byte-for-byte the legacy release/v3.0 form +// ("source->dest[asset]", no spaces, ";" separator); colored postings extend it +// with "/color" inside the brackets so uncolored consumers see no change. +func TestComputeFlowDisplaySignature(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + postings []*servicepb.NormalizedPosting + want string + }{ + { + name: "single uncolored is byte-identical to legacy", + postings: []*servicepb.NormalizedPosting{{SourcePattern: "world", DestinationPattern: "bank:main", Asset: "USD"}}, + want: "world->bank:main[USD]", + }, + { + name: "single colored extends inside the brackets", + postings: []*servicepb.NormalizedPosting{{SourcePattern: "world", DestinationPattern: "bank:main", Asset: "USD", Color: "RED"}}, + want: "world->bank:main[USD/RED]", + }, + { + name: "multi posting sorted and semicolon-joined (no spaces)", + postings: []*servicepb.NormalizedPosting{ + {SourcePattern: "world", DestinationPattern: "bank:fees", Asset: "EUR"}, + {SourcePattern: "alice", DestinationPattern: "bob", Asset: "USD", Color: "GRANTS"}, + }, + want: "alice->bob[USD/GRANTS];world->bank:fees[EUR]", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := computeFlowDisplaySignature(tc.postings) + require.Equal(t, tc.want, got) + require.NotContains(t, got, "\x00", "public signature must not contain the internal NUL separator") + }) + } +} + +// TestAnalyzeTransactions_PublicSignatureIsHumanReadable end-to-ends the wire +// contract: FlowPattern.signature must be the legacy human-readable form, never +// the NUL-delimited internal grouping key. +func TestAnalyzeTransactions_PublicSignatureIsHumanReadable(t *testing.T) { + t.Parallel() + + txns := []CompactTransaction{ + makeCompactTransaction(1000000, []CompactPosting{ + makeCompactPosting("world", "bank:main", "USD", 100), + }), + } + + resp := analyzeTransactions(txns, 0) + require.Len(t, resp.GetFlowPatterns(), 1) + sig := resp.GetFlowPatterns()[0].GetSignature() + require.Equal(t, "world->bank:main[USD]", sig) + require.NotContains(t, sig, "\x00") +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 498d2be22b..2e0b04171e 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -161,16 +161,23 @@ func IsFreezableFailure(k ErrorKind) bool { // rename an existing constant. Each constant appears in exactly one // Reason() implementation below. const ( - ErrReasonLedgerAlreadyExists = "LEDGER_ALREADY_EXISTS" - ErrReasonLedgerNotFound = "LEDGER_NOT_FOUND" - ErrReasonLedgerDeleted = "LEDGER_DELETED" - ErrReasonIdempotencyKeyConflict = "IDEMPOTENCY_KEY_CONFLICT" - ErrReasonTransactionReferenceConflict = "TRANSACTION_REFERENCE_CONFLICT" - ErrReasonTransactionReferenceNotFound = "TRANSACTION_REFERENCE_NOT_FOUND" - ErrReasonTransactionNotFound = "TRANSACTION_NOT_FOUND" - ErrReasonTransactionAlreadyReverted = "TRANSACTION_ALREADY_REVERTED" - ErrReasonInsufficientFunds = "INSUFFICIENT_FUNDS" - ErrReasonVolumeOverflow = "VOLUME_OVERFLOW" + ErrReasonLedgerAlreadyExists = "LEDGER_ALREADY_EXISTS" + ErrReasonLedgerNotFound = "LEDGER_NOT_FOUND" + ErrReasonLedgerDeleted = "LEDGER_DELETED" + ErrReasonIdempotencyKeyConflict = "IDEMPOTENCY_KEY_CONFLICT" + ErrReasonTransactionReferenceConflict = "TRANSACTION_REFERENCE_CONFLICT" + ErrReasonTransactionReferenceNotFound = "TRANSACTION_REFERENCE_NOT_FOUND" + ErrReasonTransactionNotFound = "TRANSACTION_NOT_FOUND" + ErrReasonTransactionAlreadyReverted = "TRANSACTION_ALREADY_REVERTED" + ErrReasonInsufficientFunds = "INSUFFICIENT_FUNDS" + ErrReasonVolumeOverflow = "VOLUME_OVERFLOW" + // ErrReasonAggregateOverflow is the wire constant for the read-side + // aggregator overflow. The error type itself (query.ErrAggregateOverflow) + // lives in internal/query — it is a query-only outcome, not FSM-emitted — + // but the reason string and its KindForReason classification are the + // shared wire contract and stay here. + ErrReasonAggregateOverflow = "AGGREGATE_OVERFLOW" + ErrReasonBalanceNotFound = "BALANCE_NOT_FOUND" ErrReasonBalanceNotPreloaded = "BALANCE_NOT_PRELOADED" ErrReasonNumscriptParseError = "NUMSCRIPT_PARSE_ERROR" ErrReasonValidation = "VALIDATION" @@ -419,6 +426,11 @@ var ( ErrSigningKeyIDRequired = NewValidationSentinel("signing key id is required") ErrSigningKeyIDInvalidChar = NewValidationSentinel("signing key id must contain only printable ASCII (0x20–0x7E)") ErrSigningKeyIDTooLong = NewValidationSentinel("signing key id exceeds maximum length of 256 bytes") + // Color sentinels stay local: color is a ledger-internal posting + // dimension label, not part of the Formance-wide invariants in + // github.com/formancehq/invariants. See ValidateColor in validation.go. + ErrColorInvalid = NewValidationSentinel("color must match ^[A-Z]*$ (uppercase letters only)") + ErrColorTooLong = NewValidationSentinel("color exceeds maximum length of 32 bytes") // ErrLedgerNameRequired moved to validation.go; it wraps the // github.com/formancehq/invariants sentinel. ) @@ -563,23 +575,68 @@ func (e *ErrTransactionAlreadyReverted) Metadata() map[string]string { return map[string]string{"transactionId": strconv.FormatUint(e.TransactionID, 10)} } -// ErrInsufficientFunds — source account does not have enough balance. +// ErrInsufficientFunds is returned when a source account does not have enough +// balance in the requested (asset, color) bucket. The empty color is the +// uncolored bucket and is segregated from any colored bucket of the same +// (account, asset). +// +// ColorKnown disambiguates the two meanings of an empty Color on the wire. The +// direct-posting path resolves the exact source bucket, so an empty Color there +// is the genuine uncolored bucket (ColorKnown=true). The Numscript path cannot: +// numscriptlib.MissingFundsErr carries only {Asset, Needed, Available, Range} +// and never the resolved (account, color), so a colored spend surfaces here +// with an empty Color that means "unknown", not "uncolored" (ColorKnown=false). +// Metadata() therefore omits the color key entirely when the color is unknown, +// so a client never mistakes an unresolved Numscript failure for a definite +// hit on the uncolored bucket. When a future numscript bump attaches the +// resolved bucket to MissingFundsErr, the conversion path can set the real +// Color with ColorKnown=true and this ambiguity disappears. type ErrInsufficientFunds struct { - Account string - Asset string - Amount string // requested amount (decimal string) - Balance string // available balance (decimal string) + Account string + Asset string + Color string + ColorKnown bool // false only on the Numscript path where Color is unresolved + Amount string // requested amount (decimal string) + Balance string // available balance (decimal string) } func (e *ErrInsufficientFunds) Error() string { + if !e.ColorKnown { + return fmt.Sprintf( + "insufficient funds on account %q for asset %s (color unresolved): needed %s, available %s", + e.Account, e.Asset, e.Amount, e.Balance, + ) + } + + if e.Color == "" { + return fmt.Sprintf( + "insufficient funds on account %q for asset %s: needed %s, available %s", + e.Account, e.Asset, e.Amount, e.Balance, + ) + } + return fmt.Sprintf( - "insufficient funds on account %q for asset %s: needed %s, available %s", - e.Account, e.Asset, e.Amount, e.Balance, + "insufficient funds on account %q for asset %s color %q: needed %s, available %s", + e.Account, e.Asset, e.Color, e.Amount, e.Balance, ) } func (*ErrInsufficientFunds) Reason() string { return ErrReasonInsufficientFunds } func (e *ErrInsufficientFunds) Metadata() map[string]string { - return map[string]string{"account": e.Account, "asset": e.Asset, "amount": e.Amount, "balance": e.Balance} + m := map[string]string{ + "account": e.Account, + "asset": e.Asset, + "amount": e.Amount, + "balance": e.Balance, + } + // Only publish color when the failing bucket is actually resolved. On the + // Numscript path Color is unknown (see the type doc) — emitting an empty + // string there would be read as a definite hit on the uncolored bucket, so + // we omit the key entirely and let clients treat its absence as "unknown". + if e.ColorKnown { + m["color"] = e.Color + } + + return m } // ErrVolumeOverflow — a posting would push an account's volume past 2^256. @@ -591,6 +648,7 @@ func (e *ErrInsufficientFunds) Metadata() map[string]string { type ErrVolumeOverflow struct { Account string Asset string + Color string Side string // "input" or "output" Amount string // requested amount (decimal string) Current string // current volume on that side (decimal string) @@ -598,8 +656,8 @@ type ErrVolumeOverflow struct { func (e *ErrVolumeOverflow) Error() string { return fmt.Sprintf( - "%s volume overflow on account %q for asset %s: current=%s + amount=%s exceeds 2^256", - e.Side, e.Account, e.Asset, e.Current, e.Amount, + "%s volume overflow on account %q for asset %s color=%q: current=%s + amount=%s exceeds 2^256", + e.Side, e.Account, e.Asset, e.Color, e.Current, e.Amount, ) } func (*ErrVolumeOverflow) Reason() string { return ErrReasonVolumeOverflow } @@ -607,12 +665,27 @@ func (e *ErrVolumeOverflow) Metadata() map[string]string { return map[string]string{ "account": e.Account, "asset": e.Asset, + "color": e.Color, "side": e.Side, "amount": e.Amount, "current": e.Current, } } +// ErrBalanceNotFound — balance for a source account cannot be determined. +type ErrBalanceNotFound struct { + Account string + Asset string +} + +func (e *ErrBalanceNotFound) Error() string { + return fmt.Sprintf("balance not found for account %q asset %q", e.Account, e.Asset) +} +func (*ErrBalanceNotFound) Reason() string { return ErrReasonBalanceNotFound } +func (e *ErrBalanceNotFound) Metadata() map[string]string { + return map[string]string{"account": e.Account, "asset": e.Asset} +} + // ErrSinkAlreadyExists — adding a sink that already exists. type ErrSinkAlreadyExists struct { Name string @@ -1080,22 +1153,27 @@ func (e *ErrDependencyDiscoveryFailed) Metadata() map[string]string { return map[string]string{"details": e.Error()} } -// ErrBalanceNotPreloaded — a balance the script reads was not preloaded into -// the cache by admission. A transient server-side gap (e.g. the boot-time -// bloom-populate window, #318), not a caller-satisfiable precondition: a retry -// re-runs preload and can succeed — hence KindUnavailable, and never frozen as -// an idempotency outcome. +// ErrBalanceNotPreloaded — a balance the script reads (account, asset, color) +// was not preloaded into the cache by admission. A transient server-side gap +// (e.g. the boot-time bloom-populate window, #318), not a caller-satisfiable +// precondition: a retry re-runs preload and can succeed — hence +// KindUnavailable, and never frozen as an idempotency outcome. type ErrBalanceNotPreloaded struct { Account string Asset string + Color string } func (e *ErrBalanceNotPreloaded) Error() string { - return fmt.Sprintf("balance not preloaded for account %q asset %q", e.Account, e.Asset) + if e.Color == "" { + return fmt.Sprintf("balance not preloaded for account %q asset %q", e.Account, e.Asset) + } + + return fmt.Sprintf("balance not preloaded for account %q asset %q color %q", e.Account, e.Asset, e.Color) } func (*ErrBalanceNotPreloaded) Reason() string { return ErrReasonBalanceNotPreloaded } func (e *ErrBalanceNotPreloaded) Metadata() map[string]string { - return map[string]string{"account": e.Account, "asset": e.Asset} + return map[string]string{"account": e.Account, "asset": e.Asset, "color": e.Color} } // ErrTransientAccountNonZero — one or more transient accounts held a non-zero @@ -1117,12 +1195,18 @@ func (e *ErrTransientAccountNonZero) Metadata() map[string]string { } // joinedAccounts renders the offenders as a deterministic, comma-separated -// "account/asset" list. The slice is pre-sorted by the producer, so the output -// is stable; a nil slice yields "". +// list. Each entry is "account/asset" for the uncolored bucket and +// "account/asset/color" when the offending cell carries a color, so two color +// buckets of the same (account, asset) render distinctly. The slice is +// pre-sorted by the producer, so the output is stable; a nil slice yields "". func (e *ErrTransientAccountNonZero) joinedAccounts() string { parts := make([]string, len(e.Accounts)) for i, a := range e.Accounts { - parts[i] = a.Account + "/" + a.Asset + if a.Color == "" { + parts[i] = a.Account + "/" + a.Asset + } else { + parts[i] = a.Account + "/" + a.Asset + "/" + a.Color + } } return strings.Join(parts, ", ") @@ -1287,22 +1371,23 @@ func (e *ErrNumscriptRuntime) Metadata() map[string]string { return map[string]string{"detail": e.Detail} } -// ErrVolumeNotMaterialized — a posting references a (Account, Asset) pair -// whose Input/Output volumes have not been fully fetched into the FSM's +// ErrVolumeNotMaterialized — a posting references an (Account, Asset, Color) +// tuple whose Input/Output volumes have not been fully fetched into the FSM's // working set. KindInternal: indicates a preloading miss the admission // layer should have caught; reaching this branch is a server bug. type ErrVolumeNotMaterialized struct { Account string Asset string + Color string Side string // "source" or "destination" } func (e *ErrVolumeNotMaterialized) Error() string { - return fmt.Sprintf("%s volume %s/%s not fully materialized", e.Side, e.Account, e.Asset) + return fmt.Sprintf("%s volume %s/%s color=%q not fully materialized", e.Side, e.Account, e.Asset, e.Color) } func (*ErrVolumeNotMaterialized) Reason() string { return ErrReasonVolumeNotMaterialized } func (e *ErrVolumeNotMaterialized) Metadata() map[string]string { - return map[string]string{"account": e.Account, "asset": e.Asset, "side": e.Side} + return map[string]string{"account": e.Account, "asset": e.Asset, "color": e.Color, "side": e.Side} } // ErrMetadataKeyValidation wraps another Describable to add the metadata-key diff --git a/internal/domain/errors_test.go b/internal/domain/errors_test.go index 82aabc7634..0b49683625 100644 --- a/internal/domain/errors_test.go +++ b/internal/domain/errors_test.go @@ -122,9 +122,14 @@ func TestErrorTypes(t *testing.T) { }, { name: "ErrInsufficientFunds", - err: &ErrInsufficientFunds{Account: "bank", Asset: "USD", Amount: "1000", Balance: "500"}, + err: &ErrInsufficientFunds{Account: "bank", Asset: "USD", ColorKnown: true, Amount: "1000", Balance: "500"}, expected: `insufficient funds on account "bank" for asset USD: needed 1000, available 500`, }, + { + name: "ErrInsufficientFundsColorUnresolved", + err: &ErrInsufficientFunds{Account: "bank", Asset: "USD", Amount: "1000", Balance: "500"}, + expected: `insufficient funds on account "bank" for asset USD (color unresolved): needed 1000, available 500`, + }, { name: "ErrSinkAlreadyExists", err: &ErrSinkAlreadyExists{Name: "nats-1"}, @@ -204,6 +209,41 @@ func TestErrTransientAccountNonZeroMetadata(t *testing.T) { empty := &ErrTransientAccountNonZero{} require.Equal(t, map[string]string{"accounts": ""}, empty.Metadata()) require.Equal(t, "transient accounts with non-zero balance at end of batch (input != output): ", empty.Error()) + + // Colored offenders render as account/asset/color so two color buckets of + // the same (account, asset) stay distinct — the uncolored bucket keeps the + // bare account/asset form. + colored := &ErrTransientAccountNonZero{Accounts: []AccountAssetKey{ + {Account: "staging:a", Asset: "USD"}, + {Account: "staging:a", Asset: "USD", Color: "RED"}, + }} + require.Equal(t, map[string]string{"accounts": "staging:a/USD, staging:a/USD/RED"}, colored.Metadata()) +} + +// TestErrInsufficientFundsColorMetadata pins the wire disambiguation of the +// color bucket: the direct-posting path resolves the exact bucket (ColorKnown) +// so the color key is always published — an empty string there is the genuine +// uncolored bucket. The Numscript path cannot resolve the color, so the key is +// omitted entirely rather than emitting "" and being misread as the uncolored +// bucket. +func TestErrInsufficientFundsColorMetadata(t *testing.T) { + t.Parallel() + + // Resolved uncolored bucket: color published as "". + uncolored := &ErrInsufficientFunds{Account: "a", Asset: "USD", ColorKnown: true, Amount: "100", Balance: "40"} + require.Equal(t, "", uncolored.Metadata()["color"]) + _, present := uncolored.Metadata()["color"] + require.True(t, present, "resolved bucket must always publish the color key") + + // Resolved colored bucket: color published verbatim. + red := &ErrInsufficientFunds{Account: "a", Asset: "USD", Color: "RED", ColorKnown: true, Amount: "100", Balance: "40"} + require.Equal(t, "RED", red.Metadata()["color"]) + + // Numscript path: color unresolved, key omitted so "" is not mistaken for + // the uncolored bucket. + unknown := &ErrInsufficientFunds{Asset: "COIN", Amount: "100", Balance: "40"} + _, present = unknown.Metadata()["color"] + require.False(t, present, "unresolved color must be absent from metadata, not empty") } func TestWrapCompileError(t *testing.T) { @@ -300,6 +340,7 @@ func TestEveryDomainErrorImplementsDescribable(t *testing.T) { "ErrTransactionAlreadyReverted": &ErrTransactionAlreadyReverted{}, "ErrInsufficientFunds": &ErrInsufficientFunds{}, "ErrVolumeOverflow": &ErrVolumeOverflow{}, + "ErrBalanceNotFound": &ErrBalanceNotFound{}, "ErrSinkAlreadyExists": &ErrSinkAlreadyExists{}, "ErrSinkBatchSizeTooLarge": &ErrSinkBatchSizeTooLarge{}, "ErrMetadataNotFound": &ErrMetadataNotFound{}, diff --git a/internal/domain/keys.go b/internal/domain/keys.go index 1989a25e0c..398b0ce44a 100644 --- a/internal/domain/keys.go +++ b/internal/domain/keys.go @@ -51,14 +51,20 @@ type AccountKey struct { } // AccountAssetKey identifies a single Pebble volume cell within a ledger by -// its (account, asset) coordinates. It is the ledger-local subset of +// its (account, asset, color) coordinates. It is the ledger-local subset of // VolumeKey (no LedgerName), used as map key in code that already scopes // data per ledger — exclusion sets in the index builder and the integrity // checker, transient/purged dedup helpers, etc. Keep this type plain (no // derived fields) so it is a value-equal map key. +// +// Color is part of the identity: two color buckets of the same (account, +// asset) are distinct cells that can have different purge/keep fates. The +// empty color is the uncolored bucket and is itself just another segregated +// cell. type AccountAssetKey struct { Account string Asset string + Color string } type VolumeKey struct { @@ -73,11 +79,16 @@ type VolumeKey struct { // or aggregating. AssetBase string AssetPrecision uint8 + + // Color segregates balances within the same (account, asset). The empty + // string is the "uncolored" bucket and is the default. Color values are + // constrained to ^[A-Z]*$ at admission time (matches numscript's rule). + Color string } // NewVolumeKey creates a VolumeKey with pre-parsed AssetBase and AssetPrecision, // avoiding re-parsing on every AppendBytes call in the hot path. -func NewVolumeKey(ledgerName, account, asset string) VolumeKey { +func NewVolumeKey(ledgerName, account, asset, color string) VolumeKey { base, precision := ParseAssetPrecision(asset) return VolumeKey{ @@ -85,11 +96,21 @@ func NewVolumeKey(ledgerName, account, asset string) VolumeKey { Asset: asset, AssetBase: base, AssetPrecision: precision, + Color: color, } } // AppendBytes appends the canonical byte representation to dst and returns the -// extended slice. Format: [ledgerName padded 64B][account][sep][asset_base][precision_byte]. +// extended slice. +// +// Format: [ledgerName padded 64B][account]\x00[color]\x00[asset_base][precision_byte]. +// +// Putting color between account and asset (rather than at the end) preserves +// prefix-scan semantics: scanning by (ledgerName, account) returns every color, +// and scanning by (ledgerName, account, color) is a cheap fixed prefix lookup. +// It also keeps precision as the trailing byte, which matters because precision +// can legitimately be 0x00 (e.g. "EUR") — putting it last means no separator +// follows it, so no encoding ambiguity arises. func (bk VolumeKey) AppendBytes(dst []byte) []byte { base := bk.AssetBase precision := bk.AssetPrecision @@ -102,6 +123,8 @@ func (bk VolumeKey) AppendBytes(dst []byte) []byte { dst = appendLedgerName(dst, bk.LedgerName) dst = append(dst, bk.Account...) dst = append(dst, dal.CanonicalKeySepVolume) + dst = append(dst, bk.Color...) + dst = append(dst, dal.CanonicalKeySepVolume) dst = append(dst, base...) dst = append(dst, precision) @@ -114,6 +137,12 @@ func (bk VolumeKey) Bytes() []byte { } // Unmarshal parses canonical bytes into the VolumeKey. +// +// Layout after the ledger-name block: [account]\x00[color]\x00[asset_base][precision_byte]. +// `account` and `color` are split on their respective \x00 separators (no +// validator allows \x00 inside either upstream); `asset_base` may itself be +// empty (e.g. legacy data) but the trailing precision byte MUST be present, +// so `assetPart` must be at least 1 byte. func (bk *VolumeKey) Unmarshal(d []byte) error { name, rest, err := readLedgerName(d) if err != nil { @@ -122,21 +151,22 @@ func (bk *VolumeKey) Unmarshal(d []byte) error { bk.LedgerName = name - // The remaining bytes are [account][sep_volume=0x00][asset_base][precision_byte]. - // CanonicalKeySepVolume is 0x00; account and asset_base are byte strings - // without embedded zero bytes (validated upstream). - before, after, ok := bytes.Cut(rest, []byte{dal.CanonicalKeySepVolume}) + accountBytes, afterAccount, ok := bytes.Cut(rest, []byte{dal.CanonicalKeySepVolume}) if !ok { - return errors.New("invalid balance key bytes: missing account/asset separator") + return errors.New("invalid balance key bytes: missing account/color separator") } - bk.Account = string(before) + colorBytes, assetPart, ok := bytes.Cut(afterAccount, []byte{dal.CanonicalKeySepVolume}) + if !ok { + return errors.New("invalid balance key bytes: missing color/asset separator") + } - assetPart := after - if len(assetPart) < 2 { + if len(assetPart) < 1 { return errors.New("invalid balance key bytes: asset part too short") } + bk.Account = string(accountBytes) + bk.Color = string(colorBytes) bk.AssetBase = string(assetPart[:len(assetPart)-1]) bk.AssetPrecision = assetPart[len(assetPart)-1] bk.Asset = FormatAsset(bk.AssetBase, bk.AssetPrecision) diff --git a/internal/domain/keys_test.go b/internal/domain/keys_test.go index f257539331..746a08fcf5 100644 --- a/internal/domain/keys_test.go +++ b/internal/domain/keys_test.go @@ -18,6 +18,10 @@ func padName(name string) []byte { } func newVolumeKey(ak AccountKey, asset string) VolumeKey { + return newColoredVolumeKey(ak, asset, "") +} + +func newColoredVolumeKey(ak AccountKey, asset, color string) VolumeKey { base, prec := ParseAssetPrecision(asset) return VolumeKey{ @@ -25,6 +29,7 @@ func newVolumeKey(ak AccountKey, asset string) VolumeKey { Asset: asset, AssetBase: base, AssetPrecision: prec, + Color: color, } } @@ -74,8 +79,8 @@ func TestVolumeKey_ByteFormat(t *testing.T) { vk := newVolumeKey(AccountKey{LedgerName: "test", Account: "a"}, "USD/4") data := vk.Bytes() - // Expected: [ledgerName padded 64B] "a" \x00 "USD" \x04 - expected := append(padName("test"), 'a', 0x00, 'U', 'S', 'D', 0x04) + // Expected: [ledgerName padded 64B] "a" \x00 [color=""] \x00 "USD" \x04 + expected := append(padName("test"), 'a', 0x00, 0x00, 'U', 'S', 'D', 0x04) require.Equal(t, expected, data) } @@ -89,10 +94,67 @@ func TestVolumeKey_StructLiteralFallback(t *testing.T) { } data := vk.Bytes() - expected := append(padName("test"), 'a', 0x00, 'E', 'U', 'R', 0x02) + expected := append(padName("test"), 'a', 0x00, 0x00, 'E', 'U', 'R', 0x02) + require.Equal(t, expected, data) +} + +func TestVolumeKey_ColorByteFormat(t *testing.T) { + t.Parallel() + + vk := newColoredVolumeKey(AccountKey{LedgerName: "test", Account: "a"}, "USD/4", "RED") + + data := vk.Bytes() + // Expected: [ledgerName padded 64B] "a" \x00 "RED" \x00 "USD" \x04 + expected := append(padName("test"), 'a', 0x00, 'R', 'E', 'D', 0x00, 'U', 'S', 'D', 0x04) require.Equal(t, expected, data) } +func TestVolumeKey_ColorRoundTrip(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + asset string + color string + }{ + {"uncolored EUR", "EUR", ""}, + {"uncolored USD/4", "USD/4", ""}, + {"colored RED USD/4", "USD/4", "RED"}, + {"colored GRANTS USD", "USD", "GRANTS"}, + {"colored long name BTC/8", "BTC/8", "TREASURY"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + vk := newColoredVolumeKey(AccountKey{LedgerName: "test", Account: "users:alice"}, tc.asset, tc.color) + data := vk.Bytes() + + var decoded VolumeKey + require.NoError(t, decoded.Unmarshal(data)) + require.Equal(t, vk, decoded) + require.Equal(t, tc.color, decoded.Color) + }) + } +} + +// Distinct colors on the same (account, asset) must serialize to distinct +// byte sequences — the whole point of the segregation. +func TestVolumeKey_ColorSegregatesBytes(t *testing.T) { + t.Parallel() + + ak := AccountKey{LedgerName: "test", Account: "alice"} + + uncolored := newColoredVolumeKey(ak, "USD/2", "").Bytes() + red := newColoredVolumeKey(ak, "USD/2", "RED").Bytes() + blue := newColoredVolumeKey(ak, "USD/2", "BLUE").Bytes() + + require.NotEqual(t, uncolored, red) + require.NotEqual(t, uncolored, blue) + require.NotEqual(t, red, blue) +} + func TestMetadataKey_RoundTrip(t *testing.T) { t.Parallel() diff --git a/internal/domain/processing/numscript/emulate.go b/internal/domain/processing/numscript/emulate.go index b73551c0d9..d44dd4ff99 100644 --- a/internal/domain/processing/numscript/emulate.go +++ b/internal/domain/processing/numscript/emulate.go @@ -4,6 +4,7 @@ import ( "context" "maps" "math/big" + "strings" numscriptlib "github.com/formancehq/numscript" @@ -54,18 +55,29 @@ func (s *discoveryStore) GetBalances(_ context.Context, query numscriptlib.Balan s.balancesCalled = true - balances := make(numscriptlib.Balances, len(query)) - for account, assets := range query { - accountBalance := make(numscriptlib.AccountBalance, len(assets)) - - balances[account] = accountBalance - for _, asset := range assets { - s.queriedVolumes[domain.VolumeKey{ - AccountKey: domain.AccountKey{Account: account}, - Asset: asset, - }] = struct{}{} - accountBalance[asset] = new(big.Int).Set(MaxForceBalance) + balances := make(numscriptlib.Balances, 0, len(query)) + for _, item := range query { + // Catch-all asset queries (`BASE/*`) cannot be preloaded: discovery + // does not iterate the storage to enumerate every existing precision + // flavor of BASE on the account. Bail out with the same error the + // apply-time adapter returns so admission rejects the script up + // front instead of producing a phantom Need pointing at "BASE/*". + if strings.HasSuffix(item.Asset, "/*") { + return nil, ErrCatchAllAssetNotSupported } + + s.queriedVolumes[domain.VolumeKey{ + AccountKey: domain.AccountKey{Account: item.Account}, + Asset: item.Asset, + Color: item.Color, + }] = struct{}{} + + balances = append(balances, numscriptlib.BalanceRow{ + Account: item.Account, + Asset: item.Asset, + Color: item.Color, + Amount: new(big.Int).Set(MaxForceBalance), + }) } return balances, nil @@ -141,6 +153,7 @@ func DiscoverNumscriptDependencies(cache *NumscriptCache, script string, vars ma sourceVolumes[domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: posting.Source}, Asset: posting.Asset, + Color: posting.Color, }] = struct{}{} if destinationVolumes == nil { @@ -150,6 +163,7 @@ func DiscoverNumscriptDependencies(cache *NumscriptCache, script string, vars ma destinationVolumes[domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: posting.Destination}, Asset: posting.Asset, + Color: posting.Color, }] = struct{}{} } } diff --git a/internal/domain/processing/numscript/emulate_test.go b/internal/domain/processing/numscript/emulate_test.go index c652f9f12b..aa27955795 100644 --- a/internal/domain/processing/numscript/emulate_test.go +++ b/internal/domain/processing/numscript/emulate_test.go @@ -1,10 +1,13 @@ package numscript import ( + "context" "testing" "github.com/stretchr/testify/require" + numscriptlib "github.com/formancehq/numscript" + "github.com/formancehq/ledger/v3/internal/domain" ) @@ -292,3 +295,22 @@ func TestDiscoverNumscriptDependencies(t *testing.T) { require.Nil(t, result) }) } + +// TestDiscoveryStore_RejectsCatchAllAsset pins the surface-loudly contract on +// the discovery side. When the numscript runtime asks for "BASE/*" — a query +// it emits to enumerate precision flavors — the ledger has no iteration +// capability to expand it, and pretending the catch-all is a literal asset +// would produce a phantom Need pointing at "BASE/*" that the FSM apply path +// could never satisfy. +func TestDiscoveryStore_RejectsCatchAllAsset(t *testing.T) { + t.Parallel() + + store := &discoveryStore{ + queriedVolumes: make(map[domain.VolumeKey]struct{}), + } + + _, err := store.GetBalances(context.Background(), numscriptlib.BalanceQuery{ + {Account: "alice", Asset: "USD/*"}, + }) + require.ErrorIs(t, err, ErrCatchAllAssetNotSupported) +} diff --git a/internal/domain/processing/numscript/errors.go b/internal/domain/processing/numscript/errors.go index 855d2f75fc..ff4bc018d2 100644 --- a/internal/domain/processing/numscript/errors.go +++ b/internal/domain/processing/numscript/errors.go @@ -38,6 +38,24 @@ func (errMetaNotSupported) Metadata() map[string]string { return nil } var ErrMetaNotSupported domain.Describable = errMetaNotSupported{} +// ErrCatchAllAssetNotSupported is returned when a Numscript script triggers +// the runtime's catch-all asset query (`BASE/*`) — used internally by the +// numscript interpreter to enumerate every precision flavor of an asset +// on an account when the script references the bare base. The ledger +// adapter cannot today expand the catch-all because the in-memory store +// exposes only point lookups, not iteration; until that capability lands +// we fail explicitly rather than letting the script see a phantom +// ErrBalanceNotPreloaded for `BASE/*`. +type errCatchAllAssetNotSupported struct{} + +func (errCatchAllAssetNotSupported) Error() string { + return "asset catch-all queries (BASE/*) are not yet supported: use the explicit precision (e.g. `send [USD/2 N]` instead of `send [USD N]`)" +} +func (errCatchAllAssetNotSupported) Reason() string { return domain.ErrReasonValidation } +func (errCatchAllAssetNotSupported) Metadata() map[string]string { return nil } + +var ErrCatchAllAssetNotSupported domain.Describable = errCatchAllAssetNotSupported{} + // convertNumscriptError translates known numscript library errors into domain // errors so that the gRPC error mapper can return proper status codes. Library // errors that have no specific mapping are wrapped as ErrNumscriptRuntime @@ -50,10 +68,27 @@ func convertNumscriptError(err error) domain.Describable { var missingFunds numscriptlib.MissingFundsErr if errors.As(err, &missingFunds) { + // Interpreter limitation: numscriptlib.MissingFundsErr carries only + // {Asset, Needed, Available, parser.Range} — it exposes neither the + // failing account nor the color of the bucket that ran short (see the + // pinned numscript's interpreter_error.go / interpreter.go, where the + // error is built with s.CurrentAsset only). A single asset can be + // sourced from several (account, color) buckets in one script and the + // Range points into source text, not a resolved bucket, so the color + // cannot be recovered reliably from the error alone. We therefore leave + // Color (and Account) empty rather than inventing a value that is not + // reliably known — an empty Color here means "unknown", NOT the + // uncolored bucket. ColorKnown is left false so ErrInsufficientFunds + // omits the color key from its wire metadata, keeping this "unknown" + // distinct from a definite hit on the uncolored bucket (color: ""). + // Surfacing the true color would require the interpreter to attach the + // resolved (account, color) to MissingFundsErr upstream, at which point + // this path sets the real Color with ColorKnown: true. return &domain.ErrInsufficientFunds{ Asset: missingFunds.Asset, Amount: missingFunds.Needed.String(), Balance: missingFunds.Available.String(), + // ColorKnown intentionally false: color is unresolved on this path. } } diff --git a/internal/domain/processing/numscript/errors_test.go b/internal/domain/processing/numscript/errors_test.go index 35e3d64dff..3c3c130216 100644 --- a/internal/domain/processing/numscript/errors_test.go +++ b/internal/domain/processing/numscript/errors_test.go @@ -1,6 +1,7 @@ package numscript import ( + "context" "errors" "fmt" "math/big" @@ -64,6 +65,83 @@ func TestConvertNumscriptError_MissingFunds_WrappedPreservesErrorsAs(t *testing. require.Equal(t, "500", insufficientFunds.Balance) } +// limitedColorStore serves a single, capped colored balance so a colored +// `send` overruns it and the interpreter raises MissingFundsErr. It is a +// minimal numscriptlib.Store used to exercise the real interpreter path. +type limitedColorStore struct { + account string + asset string + color string + amount *big.Int +} + +func (s limitedColorStore) GetBalances(_ context.Context, q numscriptlib.BalanceQuery) (numscriptlib.Balances, error) { + out := make(numscriptlib.Balances, 0, len(q)) + for _, item := range q { + amount := new(big.Int) + if item.Account == s.account && item.Asset == s.asset && item.Color == s.color { + amount.Set(s.amount) + } + out = append(out, numscriptlib.BalanceRow{ + Account: item.Account, + Asset: item.Asset, + Color: item.Color, + Amount: amount, + }) + } + + return out, nil +} + +func (limitedColorStore) GetAccountsMetadata(context.Context, numscriptlib.MetadataQuery) (numscriptlib.AccountsMetadata, error) { + return numscriptlib.AccountsMetadata{}, nil +} + +// TestSafeRun_ColoredInsufficientFunds runs a real colored `send` that overruns +// a capped RED bucket and asserts the surfaced ErrInsufficientFunds. It pins the +// interpreter limitation: numscriptlib.MissingFundsErr does not carry the color +// (nor the account), so the converted error's Color is empty even though the +// failing bucket is COIN/RED. An empty Color here means "unknown", NOT the +// uncolored bucket — see convertNumscriptError. If a future numscript bump +// attaches the resolved (account, color) to MissingFundsErr, this test should be +// tightened to assert Color == "RED". +func TestSafeRun_ColoredInsufficientFunds(t *testing.T) { + t.Parallel() + + script := `#![feature("experimental-asset-colors")] +send [COIN 100] ( + source = @alice \ "RED" + destination = @bob +)` + + parsed := numscriptlib.Parse(script) + require.Empty(t, parsed.GetParsingErrors()) + + store := limitedColorStore{ + account: "alice", + asset: "COIN", + color: "RED", + amount: big.NewInt(40), + } + + _, runErr := SafeRun(parsed, context.Background(), numscriptlib.VariablesMap{}, store) + require.NotNil(t, runErr) + + var insufficientFunds *domain.ErrInsufficientFunds + require.ErrorAs(t, runErr, &insufficientFunds) + require.Equal(t, "COIN", insufficientFunds.Asset) + require.Equal(t, "100", insufficientFunds.Amount) + require.Equal(t, "40", insufficientFunds.Balance) + // Interpreter limitation: color is not recoverable from MissingFundsErr. + require.Empty(t, insufficientFunds.Color) + // ColorKnown must be false so the empty color is surfaced as "unknown", not + // as a definite hit on the uncolored bucket. The wire metadata therefore + // omits the color key entirely. + require.False(t, insufficientFunds.ColorKnown) + _, colorPresent := insufficientFunds.Metadata()["color"] + require.False(t, colorPresent, "colored Numscript failure must not publish an empty color as the uncolored bucket") +} + func TestConvertNumscriptError_OtherError(t *testing.T) { t.Parallel() diff --git a/internal/domain/processing/numscript_store_adapter_test.go b/internal/domain/processing/numscript_store_adapter_test.go index e87029efa0..5e68f4af36 100644 --- a/internal/domain/processing/numscript_store_adapter_test.go +++ b/internal/domain/processing/numscript_store_adapter_test.go @@ -2,6 +2,7 @@ package processing import ( "context" + "math/big" "testing" "github.com/stretchr/testify/require" @@ -10,6 +11,7 @@ import ( numscriptlib "github.com/formancehq/numscript" "github.com/formancehq/ledger/v3/internal/domain" + "github.com/formancehq/ledger/v3/internal/domain/processing/numscript" "github.com/formancehq/ledger/v3/internal/proto/commonpb" "github.com/formancehq/ledger/v3/internal/proto/raftcmdpb" ) @@ -28,17 +30,23 @@ func TestGetBalances_ForceMode(t *testing.T) { } query := numscriptlib.BalanceQuery{ - "bank": {"USD", "EUR"}, + {Account: "bank", Asset: "USD"}, + {Account: "bank", Asset: "EUR"}, } balances, err := adapter.GetBalances(context.Background(), query) require.NoError(t, err) require.NotNil(t, balances) - // In force mode, all balances should be MaxForceBalance - require.NotNil(t, balances["bank"]["USD"]) - require.NotNil(t, balances["bank"]["EUR"]) - require.Positive(t, balances["bank"]["USD"].Sign()) + // In force mode, every queried (account, asset, color) tuple is + // materialized with MaxForceBalance under the uncolored bucket. + usd, ok := findBalance(balances, "bank", "USD", "") + require.True(t, ok) + require.Positive(t, usd.Sign()) + + eur, ok := findBalance(balances, "bank", "EUR", "") + require.True(t, ok) + require.Positive(t, eur.Sign()) } func TestGetBalances_PreloadedVolumes(t *testing.T) { @@ -54,7 +62,7 @@ func TestGetBalances_PreloadedVolumes(t *testing.T) { force: false, } - volumeKey := domain.NewVolumeKey("test", "bank", "USD") + volumeKey := domain.NewVolumeKey("test", "bank", "USD", "") // Input=1000, Output=300, Balance=700 expectGetVolume(mockStore, volumeKey, (&raftcmdpb.VolumePair{ @@ -63,13 +71,15 @@ func TestGetBalances_PreloadedVolumes(t *testing.T) { }).AsReader(), nil) query := numscriptlib.BalanceQuery{ - "bank": {"USD"}, + {Account: "bank", Asset: "USD"}, } balances, err := adapter.GetBalances(context.Background(), query) require.NoError(t, err) require.NotNil(t, balances) - require.Equal(t, int64(700), balances["bank"]["USD"].Int64()) + amt, ok := findBalance(balances, "bank", "USD", "") + require.True(t, ok) + require.Equal(t, int64(700), amt.Int64()) } func TestGetBalances_NotPreloaded(t *testing.T) { @@ -85,13 +95,13 @@ func TestGetBalances_NotPreloaded(t *testing.T) { force: false, } - volumeKey := domain.NewVolumeKey("test", "bank", "USD") + volumeKey := domain.NewVolumeKey("test", "bank", "USD", "") // Volume exists but has no input values (not preloaded) expectGetVolume(mockStore, volumeKey, (&raftcmdpb.VolumePair{}).AsReader(), nil) query := numscriptlib.BalanceQuery{ - "bank": {"USD"}, + {Account: "bank", Asset: "USD"}, } _, err := adapter.GetBalances(context.Background(), query) @@ -100,7 +110,7 @@ func TestGetBalances_NotPreloaded(t *testing.T) { } // TestGetBalances_VolumeNotFound_TreatedAsZero pins the EN-1378 contract: -// a declared-but-absent volume key (Scope.GetVolume → domain.ErrNotFound) +// a declared-but-absent volume key (Scope.Volumes().Get → domain.ErrNotFound) // is treated as a fresh zero balance by the numscript balance adapter, not // as an admission failure. The coverage gate (one layer up) is what catches // "admission forgot to declare"; ErrNotFound is the legitimate signal once @@ -118,18 +128,46 @@ func TestGetBalances_VolumeNotFound_TreatedAsZero(t *testing.T) { force: false, } - volumeKey := domain.NewVolumeKey("test", "bank", "USD") + volumeKey := domain.NewVolumeKey("test", "bank", "USD", "") expectGetVolume(mockStore, volumeKey, nil, domain.ErrNotFound) query := numscriptlib.BalanceQuery{ - "bank": {"USD"}, + {Account: "bank", Asset: "USD", Color: ""}, } balances, err := adapter.GetBalances(context.Background(), query) require.NoError(t, err) - require.NotNil(t, balances["bank"]) - require.Equal(t, "0", balances["bank"]["USD"].String()) + require.Len(t, balances, 1) + require.Equal(t, "bank", balances[0].Account) + require.Equal(t, "USD", balances[0].Asset) + require.Equal(t, "", balances[0].Color) + require.Equal(t, "0", balances[0].Amount.String()) +} + +// TestGetBalances_CatchAllRejected pins the surface-loudly contract: +// the numscript runtime emits "BASE/*" when a script references a bare +// base asset (no precision). Until the in-memory store grows an +// iterator that lets us enumerate concrete precisions, the adapter must +// surface the unsupported case rather than miss on a literal "BASE/*" +// volume key with a confusing ErrBalanceNotPreloaded. +func TestGetBalances_CatchAllRejected(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + adapter := &numscriptStoreAdapter{ + store: NewMockScope(ctrl), + ledgerName: "test", + } + + query := numscriptlib.BalanceQuery{ + {Account: "alice", Asset: "USD/*"}, + } + + _, err := adapter.GetBalances(context.Background(), query) + require.ErrorIs(t, err, numscript.ErrCatchAllAssetNotSupported) } func TestGetAccountsMetadata_Basic(t *testing.T) { @@ -261,3 +299,13 @@ func TestGetAccountsMetadata_NoSchemaLedger(t *testing.T) { require.NotNil(t, result) require.Equal(t, "25", result["users:001"]["age"]) } + +func findBalance(rows numscriptlib.Balances, account, asset, color string) (*big.Int, bool) { + for _, r := range rows { + if r.Account == account && r.Asset == asset && r.Color == color { + return r.Amount, true + } + } + + return nil, false +} diff --git a/internal/domain/processing/overlay_scope_test.go b/internal/domain/processing/overlay_scope_test.go index a2a29c562a..82afe83c51 100644 --- a/internal/domain/processing/overlay_scope_test.go +++ b/internal/domain/processing/overlay_scope_test.go @@ -147,7 +147,7 @@ func TestOrderOverlayScope_ReadYourWritesAcrossCategories(t *testing.T) { require.Equal(t, uint64(7), br.GetNextTransactionId()) // Volume - vk := domain.NewVolumeKey("L", "alice", "USD") + vk := domain.NewVolumeKey("L", "alice", "USD", "") overlay.Volumes().Put(vk, &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(50), Output: commonpb.NewUint256FromUint64(20), @@ -277,7 +277,7 @@ func TestOrderOverlayScope_CommitFlushesEveryCategory(t *testing.T) { s.parent.EXPECT().IncrementNextQueryCheckpointID().Return(uint64(1)) lk := domain.LedgerKey{Name: "L"} - vk := domain.NewVolumeKey("L", "alice", "USD") + vk := domain.NewVolumeKey("L", "alice", "USD", "") mk := domain.MetadataKey{AccountKey: domain.AccountKey{LedgerName: "L", Account: "alice"}, Key: "k"} lmk := domain.LedgerMetadataKey{LedgerName: "L", Key: "k"} tk := domain.TransactionKey{LedgerName: "L", ID: 1} diff --git a/internal/domain/processing/processor_mirror_test.go b/internal/domain/processing/processor_mirror_test.go index 395ae4f0e2..d933bf0e3f 100644 --- a/internal/domain/processing/processor_mirror_test.go +++ b/internal/domain/processing/processor_mirror_test.go @@ -112,8 +112,8 @@ func TestMirrorIngest_CreatedTransaction(t *testing.T) { Output: commonpb.NewUint256FromUint64(0), } volumes := setupVolumesStub(mockStore) - volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "world", "USD/2"), zeroVol.AsReader(), nil) - volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "users:001", "USD/2"), zeroVol.AsReader(), nil) + volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "world", "USD/2", ""), zeroVol.AsReader(), nil) + volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "users:001", "USD/2", ""), zeroVol.AsReader(), nil) // Transaction state update expectPutTransactionState(t, mockStore, @@ -311,8 +311,8 @@ func TestMirrorIngest_AdvancesLastMirrorV2LogId(t *testing.T) { Output: commonpb.NewUint256FromUint64(0), } volumes := setupVolumesStub(mockStore) - volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "world", "USD/2"), zeroVol.AsReader(), nil) - volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "users:001", "USD/2"), zeroVol.AsReader(), nil) + volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "world", "USD/2", ""), zeroVol.AsReader(), nil) + volumes.expectGet(domain.NewVolumeKey("mirror-ledger", "users:001", "USD/2", ""), zeroVol.AsReader(), nil) expectPutTransactionState(t, mockStore, domain.TransactionKey{LedgerName: "mirror-ledger", ID: 42}, nil) result, err := processor.ProcessOrder(mirrorCreatedTxOrder("mirror-ledger", 4, 42), mockStore) @@ -659,8 +659,8 @@ func TestMirrorIngest_CreatedTransaction_AbsentVolumes(t *testing.T) { // readVolumeOrZero synthesises a zero balance. expectPutVolume both // wires the stub lazily AND pins that the apply path writes both // fresh balances back through Scope.Volumes().Put. - expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "world", "USD/2"), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "users:rare-account", "USD/2"), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "world", "USD/2", ""), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "users:rare-account", "USD/2", ""), nil) expectPutTransactionState(t, mockStore, domain.TransactionKey{LedgerName: "mirror-ledger", ID: 42}, nil) @@ -730,8 +730,8 @@ func TestMirrorIngest_RevertedTransaction_AbsentVolumes(t *testing.T) { mockStore.EXPECT().PutReverted(domain.TransactionKey{LedgerName: "mirror-ledger", ID: 5}, true) expectGetTransactionState(mockStore, domain.TransactionKey{LedgerName: "mirror-ledger", ID: 5}, nil, domain.ErrNotFound) - expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "users:rare-account", "USD/2"), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "world", "USD/2"), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "users:rare-account", "USD/2", ""), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "world", "USD/2", ""), nil) expectPutTransactionState(t, mockStore, domain.TransactionKey{LedgerName: "mirror-ledger", ID: 42}, nil, func(_ domain.TransactionKey, st *commonpb.TransactionState) { require.Equal(t, uint64(5), st.GetRevertsTransaction()) @@ -802,8 +802,8 @@ func TestMirrorIngest_RevertedTransaction_LinksOriginal(t *testing.T) { mockStore.EXPECT().PutReverted(origKey, true) expectGetTransactionState(mockStore, origKey, (&commonpb.TransactionState{CreatedByLog: 7}).AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "users:rare-account", "USD/2"), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "world", "USD/2"), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "users:rare-account", "USD/2", ""), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("mirror-ledger", "world", "USD/2", ""), nil) expectPutTransactionState(t, mockStore, origKey, nil, func(_ domain.TransactionKey, st *commonpb.TransactionState) { require.Equal(t, uint64(42), st.GetRevertedByTransaction()) require.Equal(t, revertTimestamp, st.GetRevertedAt()) diff --git a/internal/domain/processing/processor_posting.go b/internal/domain/processing/processor_posting.go index d19a5725ce..9add176276 100644 --- a/internal/domain/processing/processor_posting.go +++ b/internal/domain/processing/processor_posting.go @@ -54,9 +54,9 @@ type cachedAssetPrecision struct { // cachedVolumeKey builds a VolumeKey, using the assetCache to avoid // re-parsing the asset precision when the same asset string recurs. // If assetCache is nil, falls back to domain.NewVolumeKey. -func cachedVolumeKey(ledgerName string, account, asset string, assetCache map[string]cachedAssetPrecision) domain.VolumeKey { +func cachedVolumeKey(ledgerName, account, asset, color string, assetCache map[string]cachedAssetPrecision) domain.VolumeKey { if assetCache == nil { - return domain.NewVolumeKey(ledgerName, account, asset) + return domain.NewVolumeKey(ledgerName, account, asset, color) } cached, ok := assetCache[asset] @@ -70,6 +70,7 @@ func cachedVolumeKey(ledgerName string, account, asset string, assetCache map[st Asset: asset, AssetBase: cached.base, AssetPrecision: cached.precision, + Color: color, } } @@ -78,8 +79,13 @@ func cachedVolumeKey(ledgerName string, account, asset string, assetCache map[st // increases Output for source and Input for destination. // All volumes must be preloaded by the admission layer — nil volumes return an error. // assetCache, if non-nil, avoids redundant ParseAssetPrecision calls across postings. +// +// Color is carried into both source and destination volume keys, so balances are +// strictly segregated per (account, asset, color). The empty color is the +// uncolored bucket and is itself one of these segregated buckets. func applyPosting(s Scope, ledgerName string, posting *commonpb.Posting, skipBalanceCheck bool, assetCache map[string]cachedAssetPrecision) domain.Describable { - sourceKey := cachedVolumeKey(ledgerName, posting.GetSource(), posting.GetAsset(), assetCache) + color := posting.GetColor() + sourceKey := cachedVolumeKey(ledgerName, posting.GetSource(), posting.GetAsset(), color, assetCache) // Decode posting amount into stack variable to avoid heap allocation var amount uint256.Int @@ -102,12 +108,12 @@ func applyPosting(s Scope, ledgerName string, posting *commonpb.Posting, skipBal return &domain.ErrStorageOperation{Operation: "loading source volume", Cause: err} } if sourceReader == nil { - return &domain.ErrBalanceNotPreloaded{Account: posting.GetSource(), Asset: posting.GetAsset()} + return &domain.ErrBalanceNotPreloaded{Account: posting.GetSource(), Asset: posting.GetAsset(), Color: color} } sourceVol := sourceReader.Mutate() if sourceVol.GetInput() == nil || sourceVol.GetOutput() == nil { - return &domain.ErrBalanceNotPreloaded{Account: posting.GetSource(), Asset: posting.GetAsset()} + return &domain.ErrBalanceNotPreloaded{Account: posting.GetSource(), Asset: posting.GetAsset(), Color: color} } // Balance check (skip for "world" account and when skipBalanceCheck is true) @@ -124,10 +130,12 @@ func applyPosting(s Scope, ledgerName string, posting *commonpb.Posting, skipBal balanceBig := new(big.Int).Sub(inputValue.ToBig(), outputValue.ToBig()) return &domain.ErrInsufficientFunds{ - Account: posting.GetSource(), - Asset: posting.GetAsset(), - Amount: amount.Dec(), - Balance: balanceBig.String(), + Account: posting.GetSource(), + Asset: posting.GetAsset(), + Color: color, + ColorKnown: true, // direct posting resolves the exact bucket; "" is the uncolored bucket + Amount: amount.Dec(), + Balance: balanceBig.String(), } } } @@ -148,6 +156,7 @@ func applyPosting(s Scope, ledgerName string, posting *commonpb.Posting, skipBal return &domain.ErrVolumeOverflow{ Account: posting.GetSource(), Asset: posting.GetAsset(), + Color: color, Side: "output", Amount: amount.Dec(), Current: scratch.Dec(), @@ -158,19 +167,19 @@ func applyPosting(s Scope, ledgerName string, posting *commonpb.Posting, skipBal s.Volumes().Put(sourceKey, sourceVol) // Destination receives credit - increase Input - destKey := cachedVolumeKey(ledgerName, posting.GetDestination(), posting.GetAsset(), assetCache) + destKey := cachedVolumeKey(ledgerName, posting.GetDestination(), posting.GetAsset(), color, assetCache) destReader, err := readVolumeOrZero(s, destKey) if err != nil { return &domain.ErrStorageOperation{Operation: "loading destination volume", Cause: err} } if destReader == nil { - return &domain.ErrBalanceNotPreloaded{Account: posting.GetDestination(), Asset: posting.GetAsset()} + return &domain.ErrBalanceNotPreloaded{Account: posting.GetDestination(), Asset: posting.GetAsset(), Color: color} } destVol := destReader.Mutate() if destVol.GetInput() == nil || destVol.GetOutput() == nil { - return &domain.ErrBalanceNotPreloaded{Account: posting.GetDestination(), Asset: posting.GetAsset()} + return &domain.ErrBalanceNotPreloaded{Account: posting.GetDestination(), Asset: posting.GetAsset(), Color: color} } destVol.GetInput().IntoUint256(&scratch) @@ -179,6 +188,7 @@ func applyPosting(s Scope, ledgerName string, posting *commonpb.Posting, skipBal return &domain.ErrVolumeOverflow{ Account: posting.GetDestination(), Asset: posting.GetAsset(), + Color: color, Side: "input", Amount: amount.Dec(), Current: scratch.Dec(), diff --git a/internal/domain/processing/processor_posting_test.go b/internal/domain/processing/processor_posting_test.go index e32b3b7b05..ec5d0e494f 100644 --- a/internal/domain/processing/processor_posting_test.go +++ b/internal/domain/processing/processor_posting_test.go @@ -19,8 +19,9 @@ func TestApplyPosting_WorldAccount_SkipsBalanceCheck(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "world", "USD") - destKey := domain.NewVolumeKey("test", "users:001", "USD") + + sourceKey := domain.NewVolumeKey("test", "world", "USD", "") + destKey := domain.NewVolumeKey("test", "users:001", "USD", "") zeroVol := &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(0), @@ -51,7 +52,8 @@ func TestApplyPosting_InsufficientFunds(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "bank", "USD") + + sourceKey := domain.NewVolumeKey("test", "bank", "USD", "") // Source has input=100, output=50, balance=50, but posting is 200 sourceVol := &raftcmdpb.VolumePair{ @@ -84,7 +86,8 @@ func TestApplyPosting_ZeroInputBalance(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "bank", "USD") + + sourceKey := domain.NewVolumeKey("test", "bank", "USD", "") // Source has zero input balance, Output=0 sourceVol := &raftcmdpb.VolumePair{ @@ -118,8 +121,9 @@ func TestApplyPosting_ForceSkipsBalanceCheck(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "bank", "USD") - destKey := domain.NewVolumeKey("test", "users:001", "USD") + + sourceKey := domain.NewVolumeKey("test", "bank", "USD", "") + destKey := domain.NewVolumeKey("test", "users:001", "USD", "") // Source has insufficient balance, but force=true skips the check sourceVol := &raftcmdpb.VolumePair{ @@ -154,7 +158,8 @@ func TestApplyPosting_NotPreloaded(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "bank", "USD") + + sourceKey := domain.NewVolumeKey("test", "bank", "USD", "") expectGetVolume(mockStore, sourceKey, nil, nil) //nolint:nilnil // test: nil volume @@ -183,8 +188,8 @@ func TestApplyPosting_AbsentVolumes_TreatedAsZero(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "world", "USD") - destKey := domain.NewVolumeKey("test", "users:001", "USD") + sourceKey := domain.NewVolumeKey("test", "world", "USD", "") + destKey := domain.NewVolumeKey("test", "users:001", "USD", "") // Both source (world) and destination are absent in the cache. Apply // must still succeed: world skips the balance check, dest receives the @@ -217,7 +222,7 @@ func TestApplyPosting_AbsentNonWorldSource_InsufficientFunds(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "bank", "USD") + sourceKey := domain.NewVolumeKey("test", "bank", "USD", "") // kindStub's default for an unregistered Get is ErrNotFound — exactly // the "absent in cache" state readVolumeOrZero must treat as a zero @@ -264,8 +269,9 @@ func TestApplyPosting_DestinationInputOverflow_Rejects(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "world", "USD") - destKey := domain.NewVolumeKey("test", "users:001", "USD") + + sourceKey := domain.NewVolumeKey("test", "world", "USD", "") + destKey := domain.NewVolumeKey("test", "users:001", "USD", "") // world output is 0 — safe to add anything on the source side. worldVol := &raftcmdpb.VolumePair{ @@ -315,7 +321,8 @@ func TestApplyPosting_SourceOutputOverflow_Rejects(t *testing.T) { defer ctrl.Finish() mockStore := NewMockScope(ctrl) - sourceKey := domain.NewVolumeKey("test", "world", "USD") + + sourceKey := domain.NewVolumeKey("test", "world", "USD", "") worldVol := &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(0), diff --git a/internal/domain/processing/processor_revert_transaction.go b/internal/domain/processing/processor_revert_transaction.go index d9a82af858..f69757de44 100644 --- a/internal/domain/processing/processor_revert_transaction.go +++ b/internal/domain/processing/processor_revert_transaction.go @@ -60,15 +60,17 @@ func processRevertTransaction(ledger string, order *raftcmdpb.RevertTransactionO origState := origStateReader.Mutate() // Create reversed postings and update volumes - // For a revert: original destination becomes source, original source becomes destination + // For a revert: original destination becomes source, original source becomes destination. + // Color carries over from the original posting — the funds were segregated under + // (account, asset, color) on the way out, so they must return under the same bucket. revertPostings := make([]*commonpb.Posting, len(originalPostings)) for i, originalPosting := range originalPostings { - // Create reversed posting revertPostings[i] = &commonpb.Posting{ - Source: originalPosting.GetDestination(), // Original destination is now source - Destination: originalPosting.GetSource(), // Original source is now destination + Source: originalPosting.GetDestination(), + Destination: originalPosting.GetSource(), Amount: originalPosting.GetAmount(), Asset: originalPosting.GetAsset(), + Color: originalPosting.GetColor(), } } @@ -129,10 +131,10 @@ func processRevertTransaction(ledger string, order *raftcmdpb.RevertTransactionO // Compute post-commit volumes if requested var postCommitVolumes *commonpb.PostCommitVolumes if order.GetExpandVolumes() { - var err domain.Describable - postCommitVolumes, err = buildPostCommitVolumes(s, ledger, revertPostings) - if err != nil { - return nil, err + var pcvErr domain.Describable + postCommitVolumes, pcvErr = buildPostCommitVolumes(s, ledger, revertPostings) + if pcvErr != nil { + return nil, pcvErr } } diff --git a/internal/domain/processing/processor_revert_transaction_test.go b/internal/domain/processing/processor_revert_transaction_test.go index 0f2e551c12..b476b7a21a 100644 --- a/internal/domain/processing/processor_revert_transaction_test.go +++ b/internal/domain/processing/processor_revert_transaction_test.go @@ -44,10 +44,10 @@ func TestProcessRevertTransaction_Success(t *testing.T) { // Reversed posting: destination becomes source, source becomes destination // Original: bank -> users:123 for 100 USD // Revert: users:123 -> bank for 100 USD - expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD"), sourceVol.AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD"), nil) - expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD"), destVol.AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD"), nil) + expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD", ""), sourceVol.AsReader(), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD", ""), nil) + expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD", ""), destVol.AsReader(), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD", ""), nil) mockStore.EXPECT().PutReverted(txKey, true) @@ -141,10 +141,10 @@ func TestProcessRevertTransaction_AtEffectiveDate(t *testing.T) { mockStore.EXPECT().GetReverted(txKey).Return(false, nil) mockStore.EXPECT().GetDate().Return(now.AsReader()).AnyTimes() - expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD"), sourceVol.AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD"), nil) - expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD"), destVol.AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD"), nil) + expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD", ""), sourceVol.AsReader(), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD", ""), nil) + expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD", ""), destVol.AsReader(), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD", ""), nil) mockStore.EXPECT().PutReverted(txKey, true) @@ -224,10 +224,10 @@ func TestProcessRevertTransaction_AtEffectiveDate_MissingOriginalTimestamp(t *te mockStore.EXPECT().GetReverted(txKey).Return(false, nil) mockStore.EXPECT().GetDate().Return(now.AsReader()).AnyTimes() - expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD"), sourceVol.AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD"), nil) - expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD"), destVol.AsReader(), nil) - expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD"), nil) + expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD", ""), sourceVol.AsReader(), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "users:123", "USD", ""), nil) + expectGetVolume(mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD", ""), destVol.AsReader(), nil) + expectPutVolume(t, mockStore, domain.NewVolumeKey("test-ledger", "bank", "USD", ""), nil) mockStore.EXPECT().PutReverted(txKey, true) diff --git a/internal/domain/processing/processor_test.go b/internal/domain/processing/processor_test.go index e3b3b3f541..21c7e33d8b 100644 --- a/internal/domain/processing/processor_test.go +++ b/internal/domain/processing/processor_test.go @@ -173,9 +173,9 @@ func TestCreateLedgerAndTransactInSameBatch(t *testing.T) { mockStore.EXPECT().GetCurrentOpenChapter().Return(nil, false) - // Volume operations: the LedgerID should be 1 (assigned by CreateLedger). - srcKey := domain.NewVolumeKey("myled", "world", "USD") - dstKey := domain.NewVolumeKey("myled", "users:bob", "USD") + // Volume operations: keyed by ledger name ("myled" — the ledger created above). + srcKey := domain.NewVolumeKey("myled", "world", "USD", "") + dstKey := domain.NewVolumeKey("myled", "users:bob", "USD", "") zeroVol := &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(0), diff --git a/internal/domain/processing/processor_transaction.go b/internal/domain/processing/processor_transaction.go index 371c75e4cb..92d00a4462 100644 --- a/internal/domain/processing/processor_transaction.go +++ b/internal/domain/processing/processor_transaction.go @@ -197,10 +197,10 @@ func processCreateTransaction(ledger string, order *raftcmdpb.CreateTransactionO // Compute post-commit volumes if requested var postCommitVolumes *commonpb.PostCommitVolumes if order.GetExpandVolumes() { - var err domain.Describable - postCommitVolumes, err = buildPostCommitVolumes(s, ledger, result.Postings) - if err != nil { - return nil, err + var pcvErr domain.Describable + postCommitVolumes, pcvErr = buildPostCommitVolumes(s, ledger, result.Postings) + if pcvErr != nil { + return nil, pcvErr } } @@ -246,6 +246,10 @@ func validatePostings(postings []*commonpb.Posting) domain.Describable { if err := domain.ValidateAsset(p.GetAsset()); err != nil { return err } + + if err := domain.ValidateColor(p.GetColor()); err != nil { + return err + } } return nil diff --git a/internal/domain/processing/processor_transaction_numscript.go b/internal/domain/processing/processor_transaction_numscript.go index d5a8ca0f31..e09c7981a3 100644 --- a/internal/domain/processing/processor_transaction_numscript.go +++ b/internal/domain/processing/processor_transaction_numscript.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "math/big" + "strings" "github.com/holiman/uint256" @@ -82,15 +83,16 @@ func (p *numscriptPostingProducer) produce(s Scope, ledgerName string, order *ra Destination: posting.Destination, Amount: commonpb.NewUint256(&u256Amount), Asset: posting.Asset, + Color: posting.Color, } // Update source output (money going out) - sourceKey := domain.NewVolumeKey(ledgerName, posting.Source, posting.Asset) + sourceKey := domain.NewVolumeKey(ledgerName, posting.Source, posting.Asset, posting.Color) sourceReader, err := readVolumeOrZero(s, sourceKey) if err != nil { return nil, &domain.ErrStorageOperation{ - Operation: fmt.Sprintf("source volume %s/%s", posting.Source, posting.Asset), + Operation: fmt.Sprintf("source volume %s/%s color=%q", posting.Source, posting.Asset, posting.Color), Cause: err, } } @@ -98,6 +100,7 @@ func (p *numscriptPostingProducer) produce(s Scope, ledgerName string, order *ra return nil, &domain.ErrVolumeNotMaterialized{ Account: posting.Source, Asset: posting.Asset, + Color: posting.Color, Side: "source", } } @@ -111,6 +114,7 @@ func (p *numscriptPostingProducer) produce(s Scope, ledgerName string, order *ra return nil, &domain.ErrVolumeOverflow{ Account: posting.Source, Asset: posting.Asset, + Color: posting.Color, Side: "output", Amount: u256Amount.Dec(), Current: scratch.Dec(), @@ -121,12 +125,12 @@ func (p *numscriptPostingProducer) produce(s Scope, ledgerName string, order *ra s.Volumes().Put(sourceKey, sourceVol) // Update destination input (money coming in) - destKey := domain.NewVolumeKey(ledgerName, posting.Destination, posting.Asset) + destKey := domain.NewVolumeKey(ledgerName, posting.Destination, posting.Asset, posting.Color) destReader, err := readVolumeOrZero(s, destKey) if err != nil { return nil, &domain.ErrStorageOperation{ - Operation: fmt.Sprintf("destination volume %s/%s", posting.Destination, posting.Asset), + Operation: fmt.Sprintf("destination volume %s/%s color=%q", posting.Destination, posting.Asset, posting.Color), Cause: err, } } @@ -134,6 +138,7 @@ func (p *numscriptPostingProducer) produce(s Scope, ledgerName string, order *ra return nil, &domain.ErrVolumeNotMaterialized{ Account: posting.Destination, Asset: posting.Asset, + Color: posting.Color, Side: "destination", } } @@ -145,6 +150,7 @@ func (p *numscriptPostingProducer) produce(s Scope, ledgerName string, order *ra return nil, &domain.ErrVolumeOverflow{ Account: posting.Destination, Asset: posting.Asset, + Color: posting.Color, Side: "input", Amount: u256Amount.Dec(), Current: scratch.Dec(), @@ -219,43 +225,63 @@ type numscriptStoreAdapter struct { } func (s *numscriptStoreAdapter) GetBalances(_ context.Context, query numscriptlib.BalanceQuery) (numscriptlib.Balances, error) { - balances := make(numscriptlib.Balances) + balances := make(numscriptlib.Balances, 0, len(query)) var inputVal, outputVal uint256.Int // stack scratch reused across iterations - for account, assets := range query { - accountBalance := make(numscriptlib.AccountBalance) - balances[account] = accountBalance - - for _, asset := range assets { - // When force mode is enabled, return unlimited balance for all accounts - // This bypasses all balance checks in Numscript execution - if s.force { - accountBalance[asset] = new(big.Int).Set(numscript.MaxForceBalance) - - continue - } - - volumeKey := domain.NewVolumeKey(s.ledgerName, account, asset) + for _, item := range query { + // Reject the numscript runtime's catch-all asset query (`BASE/*`) + // explicitly: the in-memory store does not expose iteration, so we + // cannot expand the wildcard to the concrete precision flavors that + // live on the account. Surface the unsupported case loudly rather + // than letting readVolumeOrZero miss on a literal "BASE/*" key. + if strings.HasSuffix(item.Asset, "/*") { + return nil, numscript.ErrCatchAllAssetNotSupported + } - vol, err := readVolumeOrZero(s.store, volumeKey) - if err != nil { - return nil, err - } + // When force mode is enabled, return unlimited balance for the + // queried (account, asset, color) tuple. This bypasses balance + // checks inside numscript while still respecting the color + // dimension numscript will use to assemble postings. + if s.force { + balances = append(balances, numscriptlib.BalanceRow{ + Account: item.Account, + Asset: item.Asset, + Color: item.Color, + Amount: new(big.Int).Set(numscript.MaxForceBalance), + }) + + continue + } - if vol == nil || vol.GetInput() == nil || vol.GetOutput() == nil { - return nil, &domain.ErrBalanceNotPreloaded{Account: account, Asset: asset} - } + volumeKey := domain.NewVolumeKey(s.ledgerName, item.Account, item.Asset, item.Color) - // Calculate balance: Input - Output using uint256, then convert to *big.Int at boundary - vol.GetInput().IntoUint256(&inputVal) - vol.GetOutput().IntoUint256(&outputVal) + vol, err := readVolumeOrZero(s.store, volumeKey) + if err != nil { + return nil, err + } - // balance escapes into the map, so it must be heap-allocated - // Convert to *big.Int at the numscript boundary (numscript uses *big.Int) - balance := new(big.Int).Sub(inputVal.ToBig(), outputVal.ToBig()) - accountBalance[asset] = balance + // Mirrors the guard in applyPosting (processor_posting.go) and produce() + // above: WriteSet.GetVolume legitimately returns (nil, nil) for a key the + // admission layer never preloaded (e.g. a colored bucket touched by a + // catch-all expansion that didn't preload everything). Calling GetInput() + // on a nil interface panics in the FSM apply path and desyncs the cluster. + if vol == nil || vol.GetInput() == nil || vol.GetOutput() == nil { + return nil, &domain.ErrBalanceNotPreloaded{Account: item.Account, Asset: item.Asset, Color: item.Color} } + + // Calculate balance: Input - Output using uint256, then convert to *big.Int at boundary + vol.GetInput().IntoUint256(&inputVal) + vol.GetOutput().IntoUint256(&outputVal) + + // balance escapes into the row, so it must be heap-allocated + // Convert to *big.Int at the numscript boundary (numscript uses *big.Int) + balances = append(balances, numscriptlib.BalanceRow{ + Account: item.Account, + Asset: item.Asset, + Color: item.Color, + Amount: new(big.Int).Sub(inputVal.ToBig(), outputVal.ToBig()), + }) } return balances, nil diff --git a/internal/domain/processing/processor_transaction_test.go b/internal/domain/processing/processor_transaction_test.go index 4409a2648e..7b74ce3e2c 100644 --- a/internal/domain/processing/processor_transaction_test.go +++ b/internal/domain/processing/processor_transaction_test.go @@ -25,8 +25,8 @@ func TestProcessCreateTransaction(t *testing.T) { now := &commonpb.Timestamp{Data: 1234567890} boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - sourceKey := domain.NewVolumeKey("test-ledger", "bank", "USD") - destKey := domain.NewVolumeKey("test-ledger", "users:123", "USD") + sourceKey := domain.NewVolumeKey("test-ledger", "bank", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "users:123", "USD", "") // Source has 1000 input, 0 output -> balance = 1000 sourceVolume := &raftcmdpb.VolumePair{ @@ -103,7 +103,7 @@ func TestProcessCreateTransaction_InsufficientFunds(t *testing.T) { boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - sourceKey := domain.NewVolumeKey("test-ledger", "users:123", "USD") + sourceKey := domain.NewVolumeKey("test-ledger", "users:123", "USD", "") // Source has only 50 balance (100 input - 50 output) sourceVolume := &raftcmdpb.VolumePair{ @@ -154,8 +154,8 @@ func TestProcessCreateTransaction_WorldSource(t *testing.T) { now := &commonpb.Timestamp{Data: 1234567890} boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - worldKey := domain.NewVolumeKey("test-ledger", "world", "USD") - destKey := domain.NewVolumeKey("test-ledger", "users:123", "USD") + worldKey := domain.NewVolumeKey("test-ledger", "world", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "users:123", "USD", "") // World has negative balance (but "world" bypasses balance check) worldVolume := &raftcmdpb.VolumePair{ @@ -1065,8 +1065,8 @@ func TestProcessCreateTransaction_Force_InsufficientFunds(t *testing.T) { now := &commonpb.Timestamp{Data: 1234567890} boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - sourceKey := domain.NewVolumeKey("test-ledger", "users:123", "USD") - destKey := domain.NewVolumeKey("test-ledger", "merchant", "USD") + sourceKey := domain.NewVolumeKey("test-ledger", "users:123", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "merchant", "USD", "") // Source has only 50 balance (100 input - 50 output) - not enough for 100 sourceVolume := &raftcmdpb.VolumePair{ @@ -1144,8 +1144,8 @@ func TestProcessCreateTransaction_Force_ZeroBalance(t *testing.T) { now := &commonpb.Timestamp{Data: 1234567890} boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - sourceKey := domain.NewVolumeKey("test-ledger", "users:new", "USD") - destKey := domain.NewVolumeKey("test-ledger", "merchant", "USD") + sourceKey := domain.NewVolumeKey("test-ledger", "users:new", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "merchant", "USD", "") // Source has zero balance, force=true skips balance check zeroVol := &raftcmdpb.VolumePair{ @@ -1371,8 +1371,8 @@ func TestProcessCreateTransaction_ChapterIdInCreatedTransaction(t *testing.T) { now := &commonpb.Timestamp{Data: 1234567890} boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - sourceKey := domain.NewVolumeKey("test-ledger", "world", "USD") - destKey := domain.NewVolumeKey("test-ledger", "users:alice", "USD") + sourceKey := domain.NewVolumeKey("test-ledger", "world", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "users:alice", "USD", "") zeroVol := &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(0), @@ -1438,8 +1438,8 @@ func TestProcessCreateTransaction_ChapterIdZeroWhenNoChapter(t *testing.T) { now := &commonpb.Timestamp{Data: 1234567890} boundaries := &raftcmdpb.LedgerBoundaries{NextTransactionId: 1, NextLogId: 1} - sourceKey := domain.NewVolumeKey("test-ledger", "world", "USD") - destKey := domain.NewVolumeKey("test-ledger", "users:bob", "USD") + sourceKey := domain.NewVolumeKey("test-ledger", "world", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "users:bob", "USD", "") zeroVol := &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(0), @@ -1519,8 +1519,8 @@ func TestProcessCreateTransaction_StoresAccountMetadataVerbatim(t *testing.T) { }, } - worldKey := domain.NewVolumeKey("test-ledger", "world", "USD") - destKey := domain.NewVolumeKey("test-ledger", "users:123", "USD") + worldKey := domain.NewVolumeKey("test-ledger", "world", "USD", "") + destKey := domain.NewVolumeKey("test-ledger", "users:123", "USD", "") zero := &raftcmdpb.VolumePair{Input: commonpb.NewUint256FromUint64(0), Output: commonpb.NewUint256FromUint64(0)} metaKey := domain.MetadataKey{ diff --git a/internal/domain/processing/processor_volumes.go b/internal/domain/processing/processor_volumes.go index 03edbccf8b..2e6cc460df 100644 --- a/internal/domain/processing/processor_volumes.go +++ b/internal/domain/processing/processor_volumes.go @@ -7,49 +7,55 @@ import ( "github.com/formancehq/ledger/v3/internal/proto/commonpb" ) -// buildPostCommitVolumes computes the post-commit volumes for all (account, asset) -// pairs involved in the given postings. It reads the current volume state from the -// in-memory store (after postings have been applied) and converts Known values -// into concrete Input/Output values as big integer strings. +// buildPostCommitVolumes computes the post-commit volumes for all +// (account, asset, color) tuples involved in the given postings. It reads the +// current volume state from the in-memory store (after postings have been +// applied) and converts Known values into concrete Input/Output values as big +// integer strings. +// +// The returned VolumesByAssets list is sorted by (asset, color) ascending so +// the response is deterministic across reads. // // Reads go through readVolumeOrZero: a declared-but-absent key (domain.ErrNotFound) // is reported as a zero balance, while any other error — notably // *state.ErrCoverageMiss, an admission-contract violation (invariants #6/#9) that // is impossible by design under a correct preload — is propagated as an // ErrStorageOperation so the order is rejected loudly (invariant #7) rather than -// returned to the client as a silently truncated volume map (EN-1440). This -// mirrors applyPosting, which reads the same source+destination keys. +// returned to the client as a silently truncated volume map (EN-1440). Two +// nodes must not emit divergent PCV payloads for the same applied index, so a +// non-NotFound store error is always surfaced. This mirrors applyPosting, which +// reads the same source+destination keys. func buildPostCommitVolumes(s Scope, ledgerName string, postings []*commonpb.Posting) (*commonpb.PostCommitVolumes, domain.Describable) { - // Collect unique (account, asset) pairs - type accountAsset struct { + type tuple struct { account string asset string + color string } - seen := make(map[accountAsset]struct{}) + seen := make(map[tuple]struct{}) - var pairs []accountAsset + var tuples []tuple - for _, p := range postings { - srcKey := accountAsset{account: p.GetSource(), asset: p.GetAsset()} - if _, ok := seen[srcKey]; !ok { - seen[srcKey] = struct{}{} - pairs = append(pairs, srcKey) + add := func(t tuple) { + if _, ok := seen[t]; ok { + return } + seen[t] = struct{}{} + tuples = append(tuples, t) + } - dstKey := accountAsset{account: p.GetDestination(), asset: p.GetAsset()} - if _, ok := seen[dstKey]; !ok { - seen[dstKey] = struct{}{} - pairs = append(pairs, dstKey) - } + for _, p := range postings { + color := p.GetColor() + add(tuple{account: p.GetSource(), asset: p.GetAsset(), color: color}) + add(tuple{account: p.GetDestination(), asset: p.GetAsset(), color: color}) } - volumesByAccount := make(map[string]*commonpb.VolumesByAssets, len(pairs)) + volumesByAccount := make(map[string]*commonpb.VolumesByAssets, len(tuples)) var scratch uint256.Int - for _, pair := range pairs { - vol, err := readVolumeOrZero(s, domain.NewVolumeKey(ledgerName, pair.account, pair.asset)) + for _, t := range tuples { + vol, err := readVolumeOrZero(s, domain.NewVolumeKey(ledgerName, t.account, t.asset, t.color)) if err != nil { return nil, &domain.ErrStorageOperation{Operation: "loading post-commit volume", Cause: err} } @@ -59,21 +65,23 @@ func buildPostCommitVolumes(s Scope, ledgerName string, postings []*commonpb.Pos vol.GetOutput().IntoUint256(&scratch) outputStr := scratch.Dec() - byAssets, ok := volumesByAccount[pair.account] + byAssets, ok := volumesByAccount[t.account] if !ok { - byAssets = &commonpb.VolumesByAssets{ - Volumes: make(map[string]*commonpb.Volumes), - } - volumesByAccount[pair.account] = byAssets - } - - byAssets.Volumes[pair.asset] = &commonpb.Volumes{ - Input: inputStr, - Output: outputStr, + byAssets = &commonpb.VolumesByAssets{} + volumesByAccount[t.account] = byAssets } + byAssets.Volumes = append(byAssets.Volumes, &commonpb.VolumeEntry{ + Asset: t.asset, + Color: t.color, + Volumes: &commonpb.Volumes{ + Input: inputStr, + Output: outputStr, + }, + }) } - return &commonpb.PostCommitVolumes{ - VolumesByAccount: volumesByAccount, - }, nil + out := &commonpb.PostCommitVolumes{VolumesByAccount: volumesByAccount} + out.SortVolumes() + + return out, nil } diff --git a/internal/domain/processing/processor_volumes_test.go b/internal/domain/processing/processor_volumes_test.go index 7663f954cb..d8e54b89a9 100644 --- a/internal/domain/processing/processor_volumes_test.go +++ b/internal/domain/processing/processor_volumes_test.go @@ -31,7 +31,7 @@ func TestBuildPostCommitVolumes_PropagatesReadError(t *testing.T) { sentinel := errors.New("simulated coverage miss") // First pair read is the posting source ("world"); program it to fail. - volumes.expectGet(domain.NewVolumeKey("test", "world", "USD"), nil, sentinel) + volumes.expectGet(domain.NewVolumeKey("test", "world", "USD", ""), nil, sentinel) postings := []*commonpb.Posting{{ Source: "world", @@ -67,7 +67,7 @@ func TestBuildPostCommitVolumes_FoundAndAbsent(t *testing.T) { Input: commonpb.NewUint256FromUint64(100), Output: commonpb.NewUint256FromUint64(40), } - volumes.expectGet(domain.NewVolumeKey("test", "bank", "USD"), sourceVol.AsReader(), nil) + volumes.expectGet(domain.NewVolumeKey("test", "bank", "USD", ""), sourceVol.AsReader(), nil) postings := []*commonpb.Posting{{ Source: "bank", @@ -80,11 +80,30 @@ func TestBuildPostCommitVolumes_FoundAndAbsent(t *testing.T) { require.Nil(t, err) require.NotNil(t, result) - bank := result.GetVolumesByAccount()["bank"].GetVolumes()["USD"] + bank := findVolumeEntry(result, "bank", "USD", "") + require.NotNil(t, bank) require.Equal(t, "100", bank.GetInput()) require.Equal(t, "40", bank.GetOutput()) - users := result.GetVolumesByAccount()["users:001"].GetVolumes()["USD"] + users := findVolumeEntry(result, "users:001", "USD", "") + require.NotNil(t, users) require.Equal(t, "0", users.GetInput()) require.Equal(t, "0", users.GetOutput()) } + +// findVolumeEntry looks up the Volumes for a single (account, asset, color) +// tuple in the flat per-account VolumeEntry list. Returns nil when absent. +func findVolumeEntry(pcv *commonpb.PostCommitVolumes, account, asset, color string) *commonpb.Volumes { + byAssets, ok := pcv.GetVolumesByAccount()[account] + if !ok { + return nil + } + + for _, e := range byAssets.GetVolumes() { + if e.GetAsset() == asset && e.GetColor() == color { + return e.GetVolumes() + } + } + + return nil +} diff --git a/internal/domain/reason.go b/internal/domain/reason.go index 3a4ba77e06..78e9551368 100644 --- a/internal/domain/reason.go +++ b/internal/domain/reason.go @@ -91,6 +91,8 @@ func KindForReason(code commonpb.ErrorReason) ErrorKind { return KindConflict case commonpb.ErrorReason_ERROR_REASON_INSUFFICIENT_FUNDS, commonpb.ErrorReason_ERROR_REASON_VOLUME_OVERFLOW, + commonpb.ErrorReason_ERROR_REASON_AGGREGATE_OVERFLOW, + commonpb.ErrorReason_ERROR_REASON_BALANCE_NOT_FOUND, commonpb.ErrorReason_ERROR_REASON_AUDIT_DISABLED, commonpb.ErrorReason_ERROR_REASON_NO_CHAPTER_OPEN, commonpb.ErrorReason_ERROR_REASON_CHAPTER_NOT_CLOSING, diff --git a/internal/domain/replay/replay.go b/internal/domain/replay/replay.go index 33ed7c2420..0df2027204 100644 --- a/internal/domain/replay/replay.go +++ b/internal/domain/replay/replay.go @@ -231,14 +231,17 @@ type pendingEphemeralPurge struct { postings []*commonpb.Posting } -// ExclusionCollector is called once per (ledger, account, asset) that the -// replay-time purge logic decides to delete from the replay store. The -// integrity checker uses it to derive its exclusion set independently of the +// ExclusionCollector is called once per (ledger, account, asset, color) that +// the replay-time purge logic decides to delete from the replay store. Color +// is part of the volume identity: two color buckets of the same (account, +// asset) can have different purge fates, and collapsing them would let +// EXCLUSION_RECORD_MISMATCH miss a real divergence. The integrity checker +// uses it to derive its exclusion set independently of the // AppliedProposal.TransientVolumes / LedgerLog.PurgedVolumes records — // neither is bound to the audit hash chain, so trusting them would let a // tampered store hide live mutations on otherwise-purged accounts. Other // replay consumers (backup rebuild) pass nil to discard. -type ExclusionCollector func(ledger, account, asset string) +type ExclusionCollector func(ledger, account, asset, color string) // EphemeralPurgeBuffer accumulates transaction postings until the caller reaches // the same proposal boundary used by the FSM's WriteSet.Merge(). @@ -349,6 +352,7 @@ func ApplyPostings( ) error { for _, posting := range postings { amount := posting.GetAmount().ToBigInt() + color := posting.GetColor() sourceKey := domain.VolumeKey{ AccountKey: domain.AccountKey{ @@ -356,6 +360,7 @@ func ApplyPostings( Account: posting.GetSource(), }, Asset: posting.GetAsset(), + Color: color, } if err := w.AddVolumeDelta(sourceKey.Bytes(), big.NewInt(0), amount); err != nil { @@ -368,6 +373,7 @@ func ApplyPostings( Account: posting.GetDestination(), }, Asset: posting.GetAsset(), + Color: color, } if err := w.AddVolumeDelta(destKey.Bytes(), amount, big.NewInt(0)); err != nil { @@ -424,6 +430,7 @@ func SimulateEphemeralPurge( vk := domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: ledger, Account: addr}, Asset: p.GetAsset(), + Color: p.GetColor(), } pair, err := w.GetVolume(vk.Bytes()) @@ -443,7 +450,7 @@ func SimulateEphemeralPurge( return fmt.Errorf("deleting ephemeral volume: %w", err) } if collector != nil { - collector(ledger, addr, p.GetAsset()) + collector(ledger, addr, p.GetAsset(), p.GetColor()) } } } diff --git a/internal/domain/touched_volume.go b/internal/domain/touched_volume.go index 0a70c7bc07..7368bf158b 100644 --- a/internal/domain/touched_volume.go +++ b/internal/domain/touched_volume.go @@ -18,7 +18,7 @@ func TouchedVolumeSet(volumes []*commonpb.TouchedVolume) map[AccountAssetKey]str set := make(map[AccountAssetKey]struct{}, len(volumes)) for _, v := range volumes { - set[AccountAssetKey{Account: v.GetAccount(), Asset: v.GetAsset()}] = struct{}{} + set[AccountAssetKey{Account: v.GetAccount(), Asset: v.GetAsset(), Color: v.GetColor()}] = struct{}{} } return set diff --git a/internal/domain/validation.go b/internal/domain/validation.go index a53628b4ad..5136381e7d 100644 --- a/internal/domain/validation.go +++ b/internal/domain/validation.go @@ -65,6 +65,12 @@ func (e *errValidation) Unwrap() error { return e.err } func (*errValidation) Reason() string { return ErrReasonValidation } func (*errValidation) Metadata() map[string]string { return nil } +// maxColorLength caps the optional Color tag on a Posting. Color is a small +// dimension label (e.g. "GRANTS", "OPS") not a free-form string; 32 bytes is +// large enough for any sensible tag and short enough to stay cheap to repeat +// inside every volume key. +const maxColorLength = 32 + // Storage-safety validation sentinels. All are Describable so they flow // through BusinessError. Each one wraps the matching primitive sentinel from // github.com/formancehq/invariants; the wrapping preserves @@ -358,6 +364,32 @@ func ValidateAsset(asset string) Describable { return wrapValidationErr(invariants.ValidateAsset(asset)) } +// ValidateColor checks that a posting Color tag is safe for use in the +// canonical volume key encoding. The Color value is embedded raw between +// two 0x00 separators in `[ledgerName 64B][account]\x00[color]\x00[asset_base][precision]` +// so any byte that aliases the separator or shifts the parser is a key-collision +// vector: two distinct (account, asset, color) tuples could fuse onto a single +// Pebble row and silently merge balances — the same class of bug as +// metadata keys (#322) and asset names (#303). +// +// The rule is ^[A-Z]*$: uppercase letters only. Empty is allowed (the +// "uncolored" bucket) but anything else must be uppercase ASCII. Length is +// capped to keep the key short. +func ValidateColor(color string) Describable { + if len(color) > maxColorLength { + return ErrColorTooLong + } + + for i := range len(color) { + c := color[i] + if c < 'A' || c > 'Z' { + return ErrColorInvalid + } + } + + return nil +} + // ParseAssetPrecision re-exports invariants.ParseAssetPrecision so existing callers // (keys.go, etc.) keep their current import path. var ParseAssetPrecision = invariants.ParseAssetPrecision diff --git a/internal/domain/validation_test.go b/internal/domain/validation_test.go index c97db11c06..834005c257 100644 --- a/internal/domain/validation_test.go +++ b/internal/domain/validation_test.go @@ -114,3 +114,38 @@ func TestValidateWrapping_UnwrapsToPrimitive(t *testing.T) { require.ErrorIs(t, ValidateLedgerName(""), invariants.ErrLedgerNameRequired) require.ErrorIs(t, ValidateAsset("lowercase"), invariants.ErrAssetInvalid) } + +func TestValidateColor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr error + }{ + {name: "empty is the uncolored bucket", input: ""}, + {name: "single letter", input: "R"}, + {name: "typical tag", input: "GRANTS"}, + {name: "max length (32)", input: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, + {name: "contains null byte", input: "A\x00B", wantErr: ErrColorInvalid}, + {name: "lowercase rejected", input: "red", wantErr: ErrColorInvalid}, + {name: "digit rejected", input: "RED2", wantErr: ErrColorInvalid}, + {name: "hyphen rejected", input: "R-G", wantErr: ErrColorInvalid}, + {name: "space rejected", input: "R G", wantErr: ErrColorInvalid}, + {name: "high byte rejected", input: "RÉD", wantErr: ErrColorInvalid}, + {name: "over max length (33)", input: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", wantErr: ErrColorTooLong}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateColor(tt.input) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/infra/receipt/receipt.go b/internal/infra/receipt/receipt.go index 65a58d438d..78bcec09a7 100644 --- a/internal/infra/receipt/receipt.go +++ b/internal/infra/receipt/receipt.go @@ -22,11 +22,19 @@ func NewSigner(key []byte) *Signer { } // PostingClaim is the JSON representation of a posting inside a JWT. +// Color is part of the receipt-bound identity because balances are +// segregated per (account, asset, color); a receipt that did not bind +// the color would be verifiable against the wrong bucket on revert. +// Color is always emitted (no `omitempty`) so uncolored claims serialize +// as `color:""` rather than dropping the field — matches the contract +// enforced by commonpb.Posting / sinkPosting / AccountVolume, and keeps +// the receipt JWT distinguishable from pre-color claim shapes. type PostingClaim struct { Source string `json:"source"` Destination string `json:"destination"` Amount string `json:"amount"` Asset string `json:"asset"` + Color string `json:"color"` } // Claims are the custom JWT claims for a transaction receipt. @@ -48,6 +56,7 @@ func (s *Signer) Sign(ledger string, txID uint64, postings []*commonpb.Posting, Destination: p.GetDestination(), Amount: p.GetAmount().Dec(), Asset: p.GetAsset(), + Color: p.GetColor(), } } @@ -117,6 +126,7 @@ func ClaimsToPostings(claims []PostingClaim) []*commonpb.Posting { Destination: c.Destination, Amount: commonpb.NewUint256(&v), Asset: c.Asset, + Color: c.Color, } } diff --git a/internal/infra/receipt/receipt_test.go b/internal/infra/receipt/receipt_test.go index 60cba92406..6cd0948bcc 100644 --- a/internal/infra/receipt/receipt_test.go +++ b/internal/infra/receipt/receipt_test.go @@ -1,6 +1,7 @@ package receipt import ( + "encoding/json" "math/big" "testing" @@ -211,3 +212,65 @@ func TestSignWithNilTimestamp(t *testing.T) { require.NoError(t, err) require.Equal(t, uint64(0), claims.ChapterID) } + +// TestSignBindsColor pins the receipt-vs-color contract: two postings +// identical in (source, destination, asset, amount) but differing in +// Color must produce distinct signatures, and the verified claims must +// expose the Color so the revert path can target the correct bucket. +func TestSignBindsColor(t *testing.T) { + t.Parallel() + + signer := NewSigner([]byte("test-secret-key-for-color-binding!!")) + + makePosting := func(color string) *commonpb.Posting { + return &commonpb.Posting{ + Source: "alice", + Destination: "bob", + Amount: commonpb.NewUint256FromUint64(100), + Asset: "USD/2", + Color: color, + } + } + + grantsTok, err := signer.Sign("ledger", 1, []*commonpb.Posting{makePosting("GRANTS")}, nil, 0) + require.NoError(t, err) + + opsTok, err := signer.Sign("ledger", 1, []*commonpb.Posting{makePosting("OPS")}, nil, 0) + require.NoError(t, err) + + require.NotEqual(t, grantsTok, opsTok, + "two postings differing only by Color must produce distinct signed receipts") + + grantsClaims, err := signer.Verify(grantsTok) + require.NoError(t, err) + require.Len(t, grantsClaims.Postings, 1) + require.Equal(t, "GRANTS", grantsClaims.Postings[0].Color) + + // ClaimsToPostings must round-trip the color so the revert path can + // target the same bucket the original transaction touched. + roundTripped := ClaimsToPostings(grantsClaims.Postings) + require.Len(t, roundTripped, 1) + require.Equal(t, "GRANTS", roundTripped[0].GetColor()) +} + +// TestPostingClaim_AlwaysEmitsColor pins the receipt-JSON contract: the +// uncolored bucket must surface as `color:""` in the signed JWT claim, +// not be dropped via omitempty. Matches the contract enforced on +// commonpb.Posting, sinkPosting, and AccountVolume — keeps the v3 +// uncolored claim distinguishable from a pre-color claim shape. +func TestPostingClaim_AlwaysEmitsColor(t *testing.T) { + t.Parallel() + + claim := PostingClaim{ + Source: "world", + Destination: "users:alice", + Amount: "100", + Asset: "USD/2", + // Color intentionally left empty — uncolored bucket. + } + + data, err := json.Marshal(claim) + require.NoError(t, err) + require.Contains(t, string(data), `"color":""`, + `uncolored receipt claims must surface "color":"" — receipt JWTs bind the (account, asset, color) identity, so the empty bucket cannot be omitted`) +} diff --git a/internal/infra/state/sentinel.go b/internal/infra/state/sentinel.go index 6424a1f6d9..4dbce8a088 100644 --- a/internal/infra/state/sentinel.go +++ b/internal/infra/state/sentinel.go @@ -288,8 +288,8 @@ func verifyVolumeDeltasMatchPostings( for _, posting := range postings { amount := posting.GetAmount().ToBigInt() - srcKey := domain.NewVolumeKey(ledgerName, posting.GetSource(), posting.GetAsset()) - dstKey := domain.NewVolumeKey(ledgerName, posting.GetDestination(), posting.GetAsset()) + srcKey := domain.NewVolumeKey(ledgerName, posting.GetSource(), posting.GetAsset(), posting.GetColor()) + dstKey := domain.NewVolumeKey(ledgerName, posting.GetDestination(), posting.GetAsset(), posting.GetColor()) if _, ok := expected[srcKey]; !ok { expected[srcKey] = &delta{input: big.NewInt(0), output: big.NewInt(0)} diff --git a/internal/infra/state/sentinel_undefined_old_test.go b/internal/infra/state/sentinel_undefined_old_test.go index ffac7f9f5b..d36142b279 100644 --- a/internal/infra/state/sentinel_undefined_old_test.go +++ b/internal/infra/state/sentinel_undefined_old_test.go @@ -59,8 +59,8 @@ func TestVerifyVolumeDeltasMatchPostings_UndefinedOldZeroBaseline(t *testing.T) // Use NewVolumeKey so AssetBase / AssetPrecision are pre-parsed and the // keys compare equal to the ones the sentinel rebuilds from postings. - srcKey := domain.NewVolumeKey(ledger, "world", "USD") - dstKey := domain.NewVolumeKey(ledger, "users:bob", "USD") + srcKey := domain.NewVolumeKey(ledger, "world", "USD", "") + dstKey := domain.NewVolumeKey(ledger, "users:bob", "USD", "") updates := []attributes.Update[domain.VolumeKey, *raftcmdpb.VolumePair]{ { diff --git a/internal/infra/state/write_set.go b/internal/infra/state/write_set.go index def61311b3..befe2cbc54 100644 --- a/internal/infra/state/write_set.go +++ b/internal/infra/state/write_set.go @@ -1088,15 +1088,17 @@ type storageFault struct { err domain.Describable } -// compareVolumeKeys orders volume keys by (Account, Asset, LedgerName). Account -// and Asset are what the returned error carries; LedgerName is a final -// tiebreaker giving a total order (map keys are unique on all three, so ties -// never occur) so the winner is fully defined when two ledgers share an -// (account, asset). +// compareVolumeKeys orders volume keys by (Account, Asset, Color, LedgerName). +// Account and Asset are what the returned error carries; Color segregates the +// per-bucket balances that share an (account, asset) under color-of-money; +// LedgerName is the final tiebreaker giving a total order (map keys are unique +// on all four, so ties never occur) so the winner is fully defined when two +// ledgers share an (account, asset, color). func compareVolumeKeys(a, b domain.VolumeKey) int { return cmp.Or( cmp.Compare(a.Account, b.Account), cmp.Compare(a.Asset, b.Asset), + cmp.Compare(a.Color, b.Color), cmp.Compare(a.LedgerName, b.LedgerName), ) } @@ -1173,7 +1175,8 @@ func (b *WriteSet) ValidateTransientVolumes(scope processing.Scope) domain.Descr // A storage/coverage fault means the check could not run correctly for at // least one key, so surface it ahead of any business offender. Pick the - // (Account, Asset, LedgerName)-smallest so the choice is deterministic. + // (Account, Asset, Color, LedgerName)-smallest so the choice is + // deterministic (compareVolumeKeys — same order both call sites use). if len(storageFaults) > 0 { return slices.MinFunc(storageFaults, func(a, b storageFault) int { return compareVolumeKeys(a.key, b.key) @@ -1185,13 +1188,17 @@ func (b *WriteSet) ValidateTransientVolumes(scope processing.Scope) domain.Descr } // One error listing every offending account, sorted by (Account, Asset, - // LedgerName) and deduplicated to (Account, Asset) granularity — the - // identity the error exposes. Sorting only the offenders (usually zero) - // keeps the byte-determinism guarantee off the happy path. + // Color, LedgerName) and deduplicated to (Account, Asset, Color) + // granularity — the identity the error exposes. Color is part of the + // identity: two color buckets of the same (account, asset) are distinct + // transient cells and must not fuse into one offender (matches the + // checker's exclusion set, which keys on Color — checker.go). Sorting + // only the offenders (usually zero) keeps the byte-determinism guarantee + // off the happy path. slices.SortFunc(offenders, compareVolumeKeys) accounts := make([]domain.AccountAssetKey, 0, len(offenders)) for _, key := range offenders { - account := domain.AccountAssetKey{Account: key.Account, Asset: key.Asset} + account := domain.AccountAssetKey{Account: key.Account, Asset: key.Asset, Color: key.Color} if n := len(accounts); n > 0 && accounts[n-1] == account { continue } @@ -1677,25 +1684,27 @@ func (b *WriteSet) PurgedVolumeKeys() []domain.VolumeKey { return b.purgedVolumeKeys } -// TransientVolumes returns the unique transient (account, asset) volumes -// per ledger, collected during Merge from the transient volume partition. +// TransientVolumes returns the unique transient (account, asset, color) +// volumes per ledger, collected during Merge from the transient volume +// partition. func (b *WriteSet) TransientVolumes() map[string][]*commonpb.TouchedVolume { return b.transientVolumes } -// collectUniqueVolumes extracts unique (account, asset) tuples per ledger -// from volume updates and emits them as deterministically-ordered -// commonpb.TouchedVolume slices. +// collectUniqueVolumes extracts unique (account, asset, color) tuples per +// ledger from volume updates and emits them as deterministically-ordered +// commonpb.TouchedVolume slices. Color is part of the identity so two color +// buckets of the same (account, asset) stay distinct in the audit log. func collectUniqueVolumes(updates []attributes.Update[domain.VolumeKey, *raftcmdpb.VolumePair]) map[string][]*commonpb.TouchedVolume { - type accAsset struct{ Account, Asset string } - seen := make(map[string]map[accAsset]struct{}) + type accAssetColor struct{ Account, Asset, Color string } + seen := make(map[string]map[accAssetColor]struct{}) for _, update := range updates { ledgerName := update.Key.LedgerName - k := accAsset{Account: update.Key.Account, Asset: update.Key.Asset} + k := accAssetColor{Account: update.Key.Account, Asset: update.Key.Asset, Color: update.Key.Color} if seen[ledgerName] == nil { - seen[ledgerName] = make(map[accAsset]struct{}) + seen[ledgerName] = make(map[accAssetColor]struct{}) } seen[ledgerName][k] = struct{}{} @@ -1703,7 +1712,7 @@ func collectUniqueVolumes(updates []attributes.Update[domain.VolumeKey, *raftcmd result := make(map[string][]*commonpb.TouchedVolume, len(seen)) for ledgerName, vols := range seen { - list := make([]accAsset, 0, len(vols)) + list := make([]accAssetColor, 0, len(vols)) for k := range vols { list = append(list, k) } @@ -1712,13 +1721,16 @@ func collectUniqueVolumes(updates []attributes.Update[domain.VolumeKey, *raftcmd if list[a].Account != list[b].Account { return list[a].Account < list[b].Account } + if list[a].Asset != list[b].Asset { + return list[a].Asset < list[b].Asset + } - return list[a].Asset < list[b].Asset + return list[a].Color < list[b].Color }) out := make([]*commonpb.TouchedVolume, len(list)) for i, k := range list { - out[i] = &commonpb.TouchedVolume{Account: k.Account, Asset: k.Asset} + out[i] = &commonpb.TouchedVolume{Account: k.Account, Asset: k.Asset, Color: k.Color} } result[ledgerName] = out diff --git a/internal/infra/state/write_set_ephemeral_purge.go b/internal/infra/state/write_set_ephemeral_purge.go index 7f2391c607..59e14ffe5a 100644 --- a/internal/infra/state/write_set_ephemeral_purge.go +++ b/internal/infra/state/write_set_ephemeral_purge.go @@ -117,44 +117,46 @@ func (b *WriteSet) partitionVolumes( return result } -// makePurgedKeySet builds a lookup set over the (ledger, account, asset) -// of every purged volume entry. Keeping the asset dimension matters: a -// multi-asset account may have one asset purged while another stays kept — -// dropping the asset would over-attribute purged state to orders touching -// the still-kept asset. +// makePurgedKeySet builds a lookup set over the (ledger, account, asset, color) +// of every purged volume entry. Keeping both asset and color dimensions +// matters: a multi-bucket account may have one (asset, color) purged while +// another stays kept — dropping either would over-attribute purged state to +// orders touching the still-kept bucket. func makePurgedKeySet(purged []attributes.Update[domain.VolumeKey, *raftcmdpb.VolumePair]) map[purgedVolumeKey]struct{} { if len(purged) == 0 { return nil } set := make(map[purgedVolumeKey]struct{}, len(purged)) for _, u := range purged { - set[purgedVolumeKey{Ledger: u.Key.LedgerName, Account: u.Key.Account, Asset: u.Key.Asset}] = struct{}{} + set[purgedVolumeKey{Ledger: u.Key.LedgerName, Account: u.Key.Account, Asset: u.Key.Asset, Color: u.Key.Color}] = struct{}{} } return set } -// purgedVolumeKey is the (ledger, account, asset) tuple used by -// makePurgedKeySet and buildPurgedByLog. The asset dimension is kept to -// avoid over-attribution in multi-asset accounts. +// purgedVolumeKey is the (ledger, account, asset, color) tuple used by +// makePurgedKeySet and buildPurgedByLog. Both dimensions are kept to avoid +// over-attribution in multi-bucket accounts: two color buckets of the same +// (account, asset) can have different purge fates. type purgedVolumeKey struct { Ledger string Account string Asset string + Color string } // buildPurgedByLog produces, for each order index, the deduplicated list of -// (account, asset) tuples that the order touched and that the proposal +// (account, asset, color) tuples that the order touched and that the proposal // classified as purged. Indexed by order_index; entries for orders that // touched nothing purged (or didn't touch volumes at all) are nil. Tuples -// within an entry are sorted (by account then asset) to keep the log payload -// deterministic across runs. +// within an entry are sorted (by account, asset, color) to keep the log +// payload deterministic across runs. func buildPurgedByLog(perOrderVolumeKeys [][]domain.VolumeKey, purged map[purgedVolumeKey]struct{}) [][]*commonpb.TouchedVolume { if len(perOrderVolumeKeys) == 0 || len(purged) == 0 { return nil } - type accAsset struct{ Account, Asset string } + type accAssetColor struct{ Account, Asset, Color string } out := make([][]*commonpb.TouchedVolume, len(perOrderVolumeKeys)) for i, keys := range perOrderVolumeKeys { @@ -162,19 +164,19 @@ func buildPurgedByLog(perOrderVolumeKeys [][]domain.VolumeKey, purged map[purged continue } - seen := make(map[accAsset]struct{}, len(keys)) + seen := make(map[accAssetColor]struct{}, len(keys)) for _, k := range keys { - if _, ok := purged[purgedVolumeKey{Ledger: k.LedgerName, Account: k.Account, Asset: k.Asset}]; !ok { + if _, ok := purged[purgedVolumeKey{Ledger: k.LedgerName, Account: k.Account, Asset: k.Asset, Color: k.Color}]; !ok { continue } - seen[accAsset{Account: k.Account, Asset: k.Asset}] = struct{}{} + seen[accAssetColor{Account: k.Account, Asset: k.Asset, Color: k.Color}] = struct{}{} } if len(seen) == 0 { continue } - ordered := make([]accAsset, 0, len(seen)) + ordered := make([]accAssetColor, 0, len(seen)) for k := range seen { ordered = append(ordered, k) } @@ -182,13 +184,16 @@ func buildPurgedByLog(perOrderVolumeKeys [][]domain.VolumeKey, purged map[purged if ordered[a].Account != ordered[b].Account { return ordered[a].Account < ordered[b].Account } + if ordered[a].Asset != ordered[b].Asset { + return ordered[a].Asset < ordered[b].Asset + } - return ordered[a].Asset < ordered[b].Asset + return ordered[a].Color < ordered[b].Color }) vols := make([]*commonpb.TouchedVolume, len(ordered)) for j, k := range ordered { - vols[j] = &commonpb.TouchedVolume{Account: k.Account, Asset: k.Asset} + vols[j] = &commonpb.TouchedVolume{Account: k.Account, Asset: k.Asset, Color: k.Color} } out[i] = vols } diff --git a/internal/infra/state/write_set_test.go b/internal/infra/state/write_set_test.go index dcb226cfa3..25ed044c9f 100644 --- a/internal/infra/state/write_set_test.go +++ b/internal/infra/state/write_set_test.go @@ -752,10 +752,11 @@ func TestWriteSetPreparedQueryBloomFilterTracksKeys(t *testing.T) { require.False(t, pqFilter.MayContain(absent), "never-inserted key must be reported absent") } -// TestCompareVolumeKeys pins the (Account, Asset, LedgerName) precedence that -// ValidateTransientVolumes relies on to pick a deterministic offender and avoid -// forking the audit hash chain (EN-1423). Account dominates, Asset breaks ties, -// LedgerName is the final tiebreaker; equal keys compare 0. +// TestCompareVolumeKeys pins the (Account, Asset, Color, LedgerName) precedence +// that ValidateTransientVolumes relies on to pick a deterministic offender and +// avoid forking the audit hash chain (EN-1423). Account dominates, Asset breaks +// ties, Color segregates same-(account, asset) buckets, LedgerName is the final +// tiebreaker; equal keys compare 0. func TestCompareVolumeKeys(t *testing.T) { t.Parallel() @@ -765,6 +766,13 @@ func TestCompareVolumeKeys(t *testing.T) { Asset: asset, } } + mkc := func(ledger, account, asset, color string) domain.VolumeKey { + return domain.VolumeKey{ + AccountKey: domain.AccountKey{LedgerName: ledger, Account: account}, + Asset: asset, + Color: color, + } + } tests := []struct { name string @@ -774,6 +782,8 @@ func TestCompareVolumeKeys(t *testing.T) { {"account dominates asset", mk("l1", "alpha", "USD"), mk("l1", "beta", "EUR"), -1}, {"account dominates ledger", mk("l9", "alpha", "USD"), mk("l1", "beta", "USD"), -1}, {"asset breaks account tie", mk("l1", "alpha", "EUR"), mk("l1", "alpha", "USD"), -1}, + {"color breaks asset tie", mkc("l1", "alpha", "USD", ""), mkc("l1", "alpha", "USD", "RED"), -1}, + {"color dominates ledger", mkc("l9", "alpha", "USD", ""), mkc("l1", "alpha", "USD", "RED"), -1}, {"ledger is final tiebreaker", mk("l1", "alpha", "USD"), mk("l3", "alpha", "USD"), -1}, {"equal keys compare 0", mk("l1", "alpha", "USD"), mk("l1", "alpha", "USD"), 0}, } @@ -821,13 +831,16 @@ func TestValidateTransientVolumesListsAllOffendersSorted(t *testing.T) { buf.Derived.Ledgers.Put(domain.LedgerKey{Name: li.GetName()}, li) } - // Four offenders across two ledgers. The last shares (account, asset) with - // the second — a cross-ledger repeat that must dedup to a single entry. + // Offenders across two ledgers. The l-b/staging:a/USD entry shares + // (account, asset) with the l-a one — a cross-ledger repeat that must dedup + // to a single entry. The RED-colored staging:a/USD is a distinct bucket and + // must NOT fuse with its uncolored sibling. offenders := []domain.VolumeKey{ - domain.NewVolumeKey("l-a", "staging:z", "USD"), - domain.NewVolumeKey("l-a", "staging:a", "USD"), - domain.NewVolumeKey("l-b", "staging:m", "EUR"), - domain.NewVolumeKey("l-b", "staging:a", "USD"), + domain.NewVolumeKey("l-a", "staging:z", "USD", ""), + domain.NewVolumeKey("l-a", "staging:a", "USD", ""), + domain.NewVolumeKey("l-a", "staging:a", "USD", "RED"), + domain.NewVolumeKey("l-b", "staging:m", "EUR", ""), + domain.NewVolumeKey("l-b", "staging:a", "USD", ""), } // Non-zero balance (input != output) => offending. Reused read-only. nonZero := &raftcmdpb.VolumePair{ @@ -859,9 +872,11 @@ func TestValidateTransientVolumesListsAllOffendersSorted(t *testing.T) { ).NewProposalScope() require.NoError(t, err) - // Sorted by (Account, Asset); the l-b/staging:a/USD repeat is deduped out. + // Sorted by (Account, Asset, Color); the l-b/staging:a/USD repeat is deduped + // out, but staging:a/USD/RED stays as its own offender (color is identity). want := []domain.AccountAssetKey{ {Account: "staging:a", Asset: "USD"}, + {Account: "staging:a", Asset: "USD", Color: "RED"}, {Account: "staging:m", Asset: "EUR"}, {Account: "staging:z", Asset: "USD"}, } @@ -905,8 +920,8 @@ func TestValidateTransientVolumesStorageFaultTakesPrecedence(t *testing.T) { require.NoError(t, err) buf.Derived.Ledgers.Put(domain.LedgerKey{Name: ledger.GetName()}, ledger) - businessOffender := domain.NewVolumeKey("l-a", "staging:a", "USD") - uncoveredOffender := domain.NewVolumeKey("l-a", "staging:z", "USD") + businessOffender := domain.NewVolumeKey("l-a", "staging:a", "USD", "") + uncoveredOffender := domain.NewVolumeKey("l-a", "staging:z", "USD", "") nonZero := &raftcmdpb.VolumePair{ Input: commonpb.NewUint256FromUint64(200), Output: commonpb.NewUint256FromUint64(50), diff --git a/internal/proto/commonpb/account.go b/internal/proto/commonpb/account.go new file mode 100644 index 0000000000..03d91d2222 --- /dev/null +++ b/internal/proto/commonpb/account.go @@ -0,0 +1,45 @@ +package commonpb + +import ( + "github.com/formancehq/ledger/v3/internal/adapter/json" +) + +// MarshalJSON implements json.Marshaler for AccountVolume. Color is always +// emitted (even when empty) so REST clients can distinguish the uncolored +// bucket from an older response shape — same contract as VolumeEntry, +// Posting, and the accountVolumeJSON shim used by Account.MarshalJSON. +// +// Account.MarshalJSON already builds accountVolumeJSON entries by hand, so +// direct serialization through that path is safe. This method covers +// any other call site (gRPC-Gateway, ad-hoc marshaling of a single row) +// that touches *AccountVolume directly. +func (x *AccountVolume) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Asset string `json:"asset"` + Color string `json:"color"` + Volumes *VolumesWithBalance `json:"volumes,omitempty"` + }{ + Asset: x.GetAsset(), + Color: x.GetColor(), + Volumes: x.GetVolumes(), + }) +} + +// FindVolume returns the VolumesWithBalance for a given (asset, color) tuple +// on this account, or nil if absent. Color "" is the uncolored bucket. +// +// Account.Volumes is a sorted list, so this is an O(n) linear scan. For +// repeated lookups, callers should build their own map. This helper exists +// to keep direct lookups ergonomic in tests and CLI rendering. +func (a *Account) FindVolume(asset, color string) *VolumesWithBalance { + if a == nil { + return nil + } + for _, entry := range a.GetVolumes() { + if entry.GetAsset() == asset && entry.GetColor() == color { + return entry.GetVolumes() + } + } + + return nil +} diff --git a/internal/proto/commonpb/account_json_test.go b/internal/proto/commonpb/account_json_test.go new file mode 100644 index 0000000000..4a5e7ef612 --- /dev/null +++ b/internal/proto/commonpb/account_json_test.go @@ -0,0 +1,33 @@ +package commonpb + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestAccountVolume_MarshalJSON_EmitsEmptyColor guards the contract that +// REST responses serializing AccountVolume directly (e.g. via gRPC-Gateway +// or ad-hoc marshaling) emit color:"" for the uncolored bucket rather +// than dropping the field via the generated omitempty tag. +func TestAccountVolume_MarshalJSON_EmitsEmptyColor(t *testing.T) { + t.Parallel() + + av := &AccountVolume{Asset: "USD/2"} + + data, err := json.Marshal(av) + require.NoError(t, err) + require.Contains(t, string(data), `"color":""`, + "uncolored AccountVolume rows must surface color:\"\" rather than dropping the field") +} + +func TestAccountVolume_MarshalJSON_EmitsColor(t *testing.T) { + t.Parallel() + + av := &AccountVolume{Asset: "USD/2", Color: "GRANTS"} + + data, err := json.Marshal(av) + require.NoError(t, err) + require.Contains(t, string(data), `"color":"GRANTS"`) +} diff --git a/internal/proto/commonpb/common.pb.go b/internal/proto/commonpb/common.pb.go index e61d6145b4..8add25c0b8 100644 --- a/internal/proto/commonpb/common.pb.go +++ b/internal/proto/commonpb/common.pb.go @@ -694,6 +694,8 @@ const ( ErrorReason_ERROR_REASON_CHECKPOINT_NOT_READY ErrorReason = 63 ErrorReason_ERROR_REASON_MIRROR_V2_LOG_ID_GAP ErrorReason = 64 ErrorReason_ERROR_REASON_MIRROR_V2_LOG_ID_INVALID ErrorReason = 65 + ErrorReason_ERROR_REASON_AGGREGATE_OVERFLOW ErrorReason = 66 + ErrorReason_ERROR_REASON_BALANCE_NOT_FOUND ErrorReason = 67 ) // Enum value maps for ErrorReason. @@ -765,6 +767,8 @@ var ( 63: "ERROR_REASON_CHECKPOINT_NOT_READY", 64: "ERROR_REASON_MIRROR_V2_LOG_ID_GAP", 65: "ERROR_REASON_MIRROR_V2_LOG_ID_INVALID", + 66: "ERROR_REASON_AGGREGATE_OVERFLOW", + 67: "ERROR_REASON_BALANCE_NOT_FOUND", } ErrorReason_value = map[string]int32{ "ERROR_REASON_UNSPECIFIED": 0, @@ -833,6 +837,8 @@ var ( "ERROR_REASON_CHECKPOINT_NOT_READY": 63, "ERROR_REASON_MIRROR_V2_LOG_ID_GAP": 64, "ERROR_REASON_MIRROR_V2_LOG_ID_INVALID": 65, + "ERROR_REASON_AGGREGATE_OVERFLOW": 66, + "ERROR_REASON_BALANCE_NOT_FOUND": 67, } ) @@ -1655,11 +1661,15 @@ func (x *Uint256) GetV3() uint64 { // Posting represents a single posting in a transaction type Posting struct { - state protoimpl.MessageState `protogen:"open.v1"` - Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` - Destination string `protobuf:"bytes,2,opt,name=destination,proto3" json:"destination,omitempty"` - Amount *Uint256 `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` - Asset string `protobuf:"bytes,4,opt,name=asset,proto3" json:"asset,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + Destination string `protobuf:"bytes,2,opt,name=destination,proto3" json:"destination,omitempty"` + Amount *Uint256 `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` + Asset string `protobuf:"bytes,4,opt,name=asset,proto3" json:"asset,omitempty"` + // Color of the funds being moved. The empty string is the "uncolored" bucket + // and is treated as just another color from a segregation standpoint. + // Color values are constrained to ^[A-Z]*$ at admission time. + Color string `protobuf:"bytes,5,opt,name=color,proto3" json:"color,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1722,6 +1732,13 @@ func (x *Posting) GetAsset() string { return "" } +func (x *Posting) GetColor() string { + if x != nil { + return x.Color + } + return "" +} + // Transaction represents a transaction type Transaction struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2023,10 +2040,12 @@ func (x *VolumesWithBalance) GetBalance() string { return "" } -// VolumesByAssets represents volumes grouped by asset +// VolumesByAssets is a sorted list of post-commit (asset, color) volume +// entries for a single account. Sorted by (asset, color) ascending so the +// serialization is deterministic and stable across reads. type VolumesByAssets struct { state protoimpl.MessageState `protogen:"open.v1"` - Volumes map[string]*Volumes `protobuf:"bytes,1,rep,name=volumes,proto3" json:"volumes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Volumes []*VolumeEntry `protobuf:"bytes,1,rep,name=volumes,proto3" json:"volumes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2061,14 +2080,77 @@ func (*VolumesByAssets) Descriptor() ([]byte, []int) { return file_common_proto_rawDescGZIP(), []int{11} } -func (x *VolumesByAssets) GetVolumes() map[string]*Volumes { +func (x *VolumesByAssets) GetVolumes() []*VolumeEntry { + if x != nil { + return x.Volumes + } + return nil +} + +// VolumeEntry is one (asset, color) row inside VolumesByAssets. The empty +// color is the uncolored bucket and is itself just another segregated bucket. +type VolumeEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Asset string `protobuf:"bytes,1,opt,name=asset,proto3" json:"asset,omitempty"` + Color string `protobuf:"bytes,2,opt,name=color,proto3" json:"color,omitempty"` + Volumes *Volumes `protobuf:"bytes,3,opt,name=volumes,proto3" json:"volumes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VolumeEntry) Reset() { + *x = VolumeEntry{} + mi := &file_common_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VolumeEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VolumeEntry) ProtoMessage() {} + +func (x *VolumeEntry) ProtoReflect() protoreflect.Message { + mi := &file_common_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 VolumeEntry.ProtoReflect.Descriptor instead. +func (*VolumeEntry) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{12} +} + +func (x *VolumeEntry) GetAsset() string { + if x != nil { + return x.Asset + } + return "" +} + +func (x *VolumeEntry) GetColor() string { + if x != nil { + return x.Color + } + return "" +} + +func (x *VolumeEntry) GetVolumes() *Volumes { if x != nil { return x.Volumes } return nil } -// PostCommitVolumes represents volumes after commit, grouped by account and asset +// PostCommitVolumes represents volumes after commit, grouped by account. +// Within each account, entries are flat-listed per (asset, color) tuple. type PostCommitVolumes struct { state protoimpl.MessageState `protogen:"open.v1"` VolumesByAccount map[string]*VolumesByAssets `protobuf:"bytes,1,rep,name=volumes_by_account,json=volumesByAccount,proto3" json:"volumes_by_account,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` @@ -2078,7 +2160,7 @@ type PostCommitVolumes struct { func (x *PostCommitVolumes) Reset() { *x = PostCommitVolumes{} - mi := &file_common_proto_msgTypes[12] + mi := &file_common_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2090,7 +2172,7 @@ func (x *PostCommitVolumes) String() string { func (*PostCommitVolumes) ProtoMessage() {} func (x *PostCommitVolumes) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[12] + mi := &file_common_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2103,7 +2185,7 @@ func (x *PostCommitVolumes) ProtoReflect() protoreflect.Message { // Deprecated: Use PostCommitVolumes.ProtoReflect.Descriptor instead. func (*PostCommitVolumes) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{12} + return file_common_proto_rawDescGZIP(), []int{13} } func (x *PostCommitVolumes) GetVolumesByAccount() map[string]*VolumesByAssets { @@ -2113,22 +2195,87 @@ func (x *PostCommitVolumes) GetVolumesByAccount() map[string]*VolumesByAssets { return nil } -// Account represents an account in the ledger +// AccountVolume is one (asset, color) row in Account.volumes. +type AccountVolume struct { + state protoimpl.MessageState `protogen:"open.v1"` + Asset string `protobuf:"bytes,1,opt,name=asset,proto3" json:"asset,omitempty"` + Color string `protobuf:"bytes,2,opt,name=color,proto3" json:"color,omitempty"` + Volumes *VolumesWithBalance `protobuf:"bytes,3,opt,name=volumes,proto3" json:"volumes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountVolume) Reset() { + *x = AccountVolume{} + mi := &file_common_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountVolume) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountVolume) ProtoMessage() {} + +func (x *AccountVolume) ProtoReflect() protoreflect.Message { + mi := &file_common_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 AccountVolume.ProtoReflect.Descriptor instead. +func (*AccountVolume) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{14} +} + +func (x *AccountVolume) GetAsset() string { + if x != nil { + return x.Asset + } + return "" +} + +func (x *AccountVolume) GetColor() string { + if x != nil { + return x.Color + } + return "" +} + +func (x *AccountVolume) GetVolumes() *VolumesWithBalance { + if x != nil { + return x.Volumes + } + return nil +} + +// Account represents an account in the ledger. type Account struct { - state protoimpl.MessageState `protogen:"open.v1"` - Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` - Metadata map[string]*MetadataValue `protobuf:"bytes,2,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - FirstUsage *Timestamp `protobuf:"bytes,3,opt,name=first_usage,json=firstUsage,proto3" json:"first_usage,omitempty"` - InsertionDate *Timestamp `protobuf:"bytes,4,opt,name=insertion_date,json=insertionDate,proto3" json:"insertion_date,omitempty"` - UpdatedAt *Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - Volumes map[string]*VolumesWithBalance `protobuf:"bytes,6,rep,name=volumes,proto3" json:"volumes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Volumes per asset + state protoimpl.MessageState `protogen:"open.v1"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + Metadata map[string]*MetadataValue `protobuf:"bytes,2,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + FirstUsage *Timestamp `protobuf:"bytes,3,opt,name=first_usage,json=firstUsage,proto3" json:"first_usage,omitempty"` + InsertionDate *Timestamp `protobuf:"bytes,4,opt,name=insertion_date,json=insertionDate,proto3" json:"insertion_date,omitempty"` + UpdatedAt *Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Volumes is a sorted list of per (asset, color) entries. + // Sorted by (asset, color) ascending for stable serialization. + // When the request opts into collapse_colors, the list collapses to one + // entry per asset with color = "" and amounts summed across colors. + Volumes []*AccountVolume `protobuf:"bytes,6,rep,name=volumes,proto3" json:"volumes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Account) Reset() { *x = Account{} - mi := &file_common_proto_msgTypes[13] + mi := &file_common_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2140,7 +2287,7 @@ func (x *Account) String() string { func (*Account) ProtoMessage() {} func (x *Account) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[13] + mi := &file_common_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2153,7 +2300,7 @@ func (x *Account) ProtoReflect() protoreflect.Message { // Deprecated: Use Account.ProtoReflect.Descriptor instead. func (*Account) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{13} + return file_common_proto_rawDescGZIP(), []int{15} } func (x *Account) GetAddress() string { @@ -2191,7 +2338,7 @@ func (x *Account) GetUpdatedAt() *Timestamp { return nil } -func (x *Account) GetVolumes() map[string]*VolumesWithBalance { +func (x *Account) GetVolumes() []*AccountVolume { if x != nil { return x.Volumes } @@ -2207,7 +2354,7 @@ type TargetAccount struct { func (x *TargetAccount) Reset() { *x = TargetAccount{} - mi := &file_common_proto_msgTypes[14] + mi := &file_common_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2219,7 +2366,7 @@ func (x *TargetAccount) String() string { func (*TargetAccount) ProtoMessage() {} func (x *TargetAccount) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[14] + mi := &file_common_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2232,7 +2379,7 @@ func (x *TargetAccount) ProtoReflect() protoreflect.Message { // Deprecated: Use TargetAccount.ProtoReflect.Descriptor instead. func (*TargetAccount) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{14} + return file_common_proto_rawDescGZIP(), []int{16} } func (x *TargetAccount) GetAddr() string { @@ -2255,7 +2402,7 @@ type Target struct { func (x *Target) Reset() { *x = Target{} - mi := &file_common_proto_msgTypes[15] + mi := &file_common_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2267,7 +2414,7 @@ func (x *Target) String() string { func (*Target) ProtoMessage() {} func (x *Target) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[15] + mi := &file_common_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2280,7 +2427,7 @@ func (x *Target) ProtoReflect() protoreflect.Message { // Deprecated: Use Target.ProtoReflect.Descriptor instead. func (*Target) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{15} + return file_common_proto_rawDescGZIP(), []int{17} } func (x *Target) GetTarget() isTarget_Target { @@ -2333,7 +2480,7 @@ type MetadataFieldSchema struct { func (x *MetadataFieldSchema) Reset() { *x = MetadataFieldSchema{} - mi := &file_common_proto_msgTypes[16] + mi := &file_common_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2345,7 +2492,7 @@ func (x *MetadataFieldSchema) String() string { func (*MetadataFieldSchema) ProtoMessage() {} func (x *MetadataFieldSchema) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[16] + mi := &file_common_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2358,7 +2505,7 @@ func (x *MetadataFieldSchema) ProtoReflect() protoreflect.Message { // Deprecated: Use MetadataFieldSchema.ProtoReflect.Descriptor instead. func (*MetadataFieldSchema) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{16} + return file_common_proto_rawDescGZIP(), []int{18} } func (x *MetadataFieldSchema) GetType() MetadataType { @@ -2379,7 +2526,7 @@ type MetadataSchema struct { func (x *MetadataSchema) Reset() { *x = MetadataSchema{} - mi := &file_common_proto_msgTypes[17] + mi := &file_common_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2391,7 +2538,7 @@ func (x *MetadataSchema) String() string { func (*MetadataSchema) ProtoMessage() {} func (x *MetadataSchema) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[17] + mi := &file_common_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2404,7 +2551,7 @@ func (x *MetadataSchema) ProtoReflect() protoreflect.Message { // Deprecated: Use MetadataSchema.ProtoReflect.Descriptor instead. func (*MetadataSchema) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{17} + return file_common_proto_rawDescGZIP(), []int{19} } func (x *MetadataSchema) GetAccountFields() map[string]*MetadataFieldSchema { @@ -2439,7 +2586,7 @@ type SetMetadataFieldTypeCommand struct { func (x *SetMetadataFieldTypeCommand) Reset() { *x = SetMetadataFieldTypeCommand{} - mi := &file_common_proto_msgTypes[18] + mi := &file_common_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2451,7 +2598,7 @@ func (x *SetMetadataFieldTypeCommand) String() string { func (*SetMetadataFieldTypeCommand) ProtoMessage() {} func (x *SetMetadataFieldTypeCommand) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[18] + mi := &file_common_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2464,7 +2611,7 @@ func (x *SetMetadataFieldTypeCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMetadataFieldTypeCommand.ProtoReflect.Descriptor instead. func (*SetMetadataFieldTypeCommand) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{18} + return file_common_proto_rawDescGZIP(), []int{20} } func (x *SetMetadataFieldTypeCommand) GetTargetType() TargetType { @@ -2499,7 +2646,7 @@ type MetadataIndexID struct { func (x *MetadataIndexID) Reset() { *x = MetadataIndexID{} - mi := &file_common_proto_msgTypes[19] + mi := &file_common_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2511,7 +2658,7 @@ func (x *MetadataIndexID) String() string { func (*MetadataIndexID) ProtoMessage() {} func (x *MetadataIndexID) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[19] + mi := &file_common_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2524,7 +2671,7 @@ func (x *MetadataIndexID) ProtoReflect() protoreflect.Message { // Deprecated: Use MetadataIndexID.ProtoReflect.Descriptor instead. func (*MetadataIndexID) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{19} + return file_common_proto_rawDescGZIP(), []int{21} } func (x *MetadataIndexID) GetTarget() TargetType { @@ -2559,7 +2706,7 @@ type IndexID struct { func (x *IndexID) Reset() { *x = IndexID{} - mi := &file_common_proto_msgTypes[20] + mi := &file_common_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2571,7 +2718,7 @@ func (x *IndexID) String() string { func (*IndexID) ProtoMessage() {} func (x *IndexID) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[20] + mi := &file_common_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2584,7 +2731,7 @@ func (x *IndexID) ProtoReflect() protoreflect.Message { // Deprecated: Use IndexID.ProtoReflect.Descriptor instead. func (*IndexID) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{20} + return file_common_proto_rawDescGZIP(), []int{22} } func (x *IndexID) GetKind() isIndexID_Kind { @@ -2694,7 +2841,7 @@ type Index struct { func (x *Index) Reset() { *x = Index{} - mi := &file_common_proto_msgTypes[21] + mi := &file_common_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2706,7 +2853,7 @@ func (x *Index) String() string { func (*Index) ProtoMessage() {} func (x *Index) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[21] + mi := &file_common_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2719,7 +2866,7 @@ func (x *Index) ProtoReflect() protoreflect.Message { // Deprecated: Use Index.ProtoReflect.Descriptor instead. func (*Index) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{21} + return file_common_proto_rawDescGZIP(), []int{23} } func (x *Index) GetId() *IndexID { @@ -2780,7 +2927,7 @@ type Idempotency struct { func (x *Idempotency) Reset() { *x = Idempotency{} - mi := &file_common_proto_msgTypes[22] + mi := &file_common_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2792,7 +2939,7 @@ func (x *Idempotency) String() string { func (*Idempotency) ProtoMessage() {} func (x *Idempotency) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[22] + mi := &file_common_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2805,7 +2952,7 @@ func (x *Idempotency) ProtoReflect() protoreflect.Message { // Deprecated: Use Idempotency.ProtoReflect.Descriptor instead. func (*Idempotency) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{22} + return file_common_proto_rawDescGZIP(), []int{24} } func (x *Idempotency) GetKey() string { @@ -2826,7 +2973,7 @@ type IdempotencyEntry struct { func (x *IdempotencyEntry) Reset() { *x = IdempotencyEntry{} - mi := &file_common_proto_msgTypes[23] + mi := &file_common_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2838,7 +2985,7 @@ func (x *IdempotencyEntry) String() string { func (*IdempotencyEntry) ProtoMessage() {} func (x *IdempotencyEntry) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[23] + mi := &file_common_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2851,7 +2998,7 @@ func (x *IdempotencyEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use IdempotencyEntry.ProtoReflect.Descriptor instead. func (*IdempotencyEntry) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{23} + return file_common_proto_rawDescGZIP(), []int{25} } func (x *IdempotencyEntry) GetHash() []byte { @@ -2880,7 +3027,7 @@ type Log struct { func (x *Log) Reset() { *x = Log{} - mi := &file_common_proto_msgTypes[24] + mi := &file_common_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2892,7 +3039,7 @@ func (x *Log) String() string { func (*Log) ProtoMessage() {} func (x *Log) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[24] + mi := &file_common_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2905,7 +3052,7 @@ func (x *Log) ProtoReflect() protoreflect.Message { // Deprecated: Use Log.ProtoReflect.Descriptor instead. func (*Log) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{24} + return file_common_proto_rawDescGZIP(), []int{26} } func (x *Log) GetSequence() uint64 { @@ -2974,7 +3121,7 @@ type LogPayload struct { func (x *LogPayload) Reset() { *x = LogPayload{} - mi := &file_common_proto_msgTypes[25] + mi := &file_common_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2986,7 +3133,7 @@ func (x *LogPayload) String() string { func (*LogPayload) ProtoMessage() {} func (x *LogPayload) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[25] + mi := &file_common_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2999,7 +3146,7 @@ func (x *LogPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use LogPayload.ProtoReflect.Descriptor instead. func (*LogPayload) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{25} + return file_common_proto_rawDescGZIP(), []int{27} } func (x *LogPayload) GetType() isLogPayload_Type { @@ -3427,7 +3574,7 @@ type PromotedLedgerLog struct { func (x *PromotedLedgerLog) Reset() { *x = PromotedLedgerLog{} - mi := &file_common_proto_msgTypes[26] + mi := &file_common_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3439,7 +3586,7 @@ func (x *PromotedLedgerLog) String() string { func (*PromotedLedgerLog) ProtoMessage() {} func (x *PromotedLedgerLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[26] + mi := &file_common_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3452,7 +3599,7 @@ func (x *PromotedLedgerLog) ProtoReflect() protoreflect.Message { // Deprecated: Use PromotedLedgerLog.ProtoReflect.Descriptor instead. func (*PromotedLedgerLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{26} + return file_common_proto_rawDescGZIP(), []int{28} } func (x *PromotedLedgerLog) GetName() string { @@ -3474,7 +3621,7 @@ type RegisteredSigningKeyLog struct { func (x *RegisteredSigningKeyLog) Reset() { *x = RegisteredSigningKeyLog{} - mi := &file_common_proto_msgTypes[27] + mi := &file_common_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3486,7 +3633,7 @@ func (x *RegisteredSigningKeyLog) String() string { func (*RegisteredSigningKeyLog) ProtoMessage() {} func (x *RegisteredSigningKeyLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[27] + mi := &file_common_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3499,7 +3646,7 @@ func (x *RegisteredSigningKeyLog) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisteredSigningKeyLog.ProtoReflect.Descriptor instead. func (*RegisteredSigningKeyLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{27} + return file_common_proto_rawDescGZIP(), []int{29} } func (x *RegisteredSigningKeyLog) GetKeyId() string { @@ -3534,7 +3681,7 @@ type RevokedSigningKeyLog struct { func (x *RevokedSigningKeyLog) Reset() { *x = RevokedSigningKeyLog{} - mi := &file_common_proto_msgTypes[28] + mi := &file_common_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3546,7 +3693,7 @@ func (x *RevokedSigningKeyLog) String() string { func (*RevokedSigningKeyLog) ProtoMessage() {} func (x *RevokedSigningKeyLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[28] + mi := &file_common_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3559,7 +3706,7 @@ func (x *RevokedSigningKeyLog) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokedSigningKeyLog.ProtoReflect.Descriptor instead. func (*RevokedSigningKeyLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{28} + return file_common_proto_rawDescGZIP(), []int{30} } func (x *RevokedSigningKeyLog) GetKeyId() string { @@ -3588,7 +3735,7 @@ type SigningKey struct { func (x *SigningKey) Reset() { *x = SigningKey{} - mi := &file_common_proto_msgTypes[29] + mi := &file_common_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3600,7 +3747,7 @@ func (x *SigningKey) String() string { func (*SigningKey) ProtoMessage() {} func (x *SigningKey) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[29] + mi := &file_common_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3613,7 +3760,7 @@ func (x *SigningKey) ProtoReflect() protoreflect.Message { // Deprecated: Use SigningKey.ProtoReflect.Descriptor instead. func (*SigningKey) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{29} + return file_common_proto_rawDescGZIP(), []int{31} } func (x *SigningKey) GetKeyId() string { @@ -3647,7 +3794,7 @@ type SetSigningConfigLog struct { func (x *SetSigningConfigLog) Reset() { *x = SetSigningConfigLog{} - mi := &file_common_proto_msgTypes[30] + mi := &file_common_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3659,7 +3806,7 @@ func (x *SetSigningConfigLog) String() string { func (*SetSigningConfigLog) ProtoMessage() {} func (x *SetSigningConfigLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[30] + mi := &file_common_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3672,7 +3819,7 @@ func (x *SetSigningConfigLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SetSigningConfigLog.ProtoReflect.Descriptor instead. func (*SetSigningConfigLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{30} + return file_common_proto_rawDescGZIP(), []int{32} } func (x *SetSigningConfigLog) GetRequireSignatures() bool { @@ -3692,7 +3839,7 @@ type AddedEventsSinkLog struct { func (x *AddedEventsSinkLog) Reset() { *x = AddedEventsSinkLog{} - mi := &file_common_proto_msgTypes[31] + mi := &file_common_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3704,7 +3851,7 @@ func (x *AddedEventsSinkLog) String() string { func (*AddedEventsSinkLog) ProtoMessage() {} func (x *AddedEventsSinkLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[31] + mi := &file_common_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3717,7 +3864,7 @@ func (x *AddedEventsSinkLog) ProtoReflect() protoreflect.Message { // Deprecated: Use AddedEventsSinkLog.ProtoReflect.Descriptor instead. func (*AddedEventsSinkLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{31} + return file_common_proto_rawDescGZIP(), []int{33} } func (x *AddedEventsSinkLog) GetConfig() *SinkConfig { @@ -3737,7 +3884,7 @@ type RemovedEventsSinkLog struct { func (x *RemovedEventsSinkLog) Reset() { *x = RemovedEventsSinkLog{} - mi := &file_common_proto_msgTypes[32] + mi := &file_common_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3749,7 +3896,7 @@ func (x *RemovedEventsSinkLog) String() string { func (*RemovedEventsSinkLog) ProtoMessage() {} func (x *RemovedEventsSinkLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[32] + mi := &file_common_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3762,7 +3909,7 @@ func (x *RemovedEventsSinkLog) ProtoReflect() protoreflect.Message { // Deprecated: Use RemovedEventsSinkLog.ProtoReflect.Descriptor instead. func (*RemovedEventsSinkLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{32} + return file_common_proto_rawDescGZIP(), []int{34} } func (x *RemovedEventsSinkLog) GetName() string { @@ -3782,7 +3929,7 @@ type SetMaintenanceModeLog struct { func (x *SetMaintenanceModeLog) Reset() { *x = SetMaintenanceModeLog{} - mi := &file_common_proto_msgTypes[33] + mi := &file_common_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3794,7 +3941,7 @@ func (x *SetMaintenanceModeLog) String() string { func (*SetMaintenanceModeLog) ProtoMessage() {} func (x *SetMaintenanceModeLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[33] + mi := &file_common_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3807,7 +3954,7 @@ func (x *SetMaintenanceModeLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMaintenanceModeLog.ProtoReflect.Descriptor instead. func (*SetMaintenanceModeLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{33} + return file_common_proto_rawDescGZIP(), []int{35} } func (x *SetMaintenanceModeLog) GetEnabled() bool { @@ -3828,7 +3975,7 @@ type BloomTypeConfig struct { func (x *BloomTypeConfig) Reset() { *x = BloomTypeConfig{} - mi := &file_common_proto_msgTypes[34] + mi := &file_common_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3840,7 +3987,7 @@ func (x *BloomTypeConfig) String() string { func (*BloomTypeConfig) ProtoMessage() {} func (x *BloomTypeConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[34] + mi := &file_common_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3853,7 +4000,7 @@ func (x *BloomTypeConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use BloomTypeConfig.ProtoReflect.Descriptor instead. func (*BloomTypeConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{34} + return file_common_proto_rawDescGZIP(), []int{36} } func (x *BloomTypeConfig) GetExpectedKeys() uint64 { @@ -3894,7 +4041,7 @@ type ClusterConfig struct { func (x *ClusterConfig) Reset() { *x = ClusterConfig{} - mi := &file_common_proto_msgTypes[35] + mi := &file_common_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3906,7 +4053,7 @@ func (x *ClusterConfig) String() string { func (*ClusterConfig) ProtoMessage() {} func (x *ClusterConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[35] + mi := &file_common_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3919,7 +4066,7 @@ func (x *ClusterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ClusterConfig.ProtoReflect.Descriptor instead. func (*ClusterConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{35} + return file_common_proto_rawDescGZIP(), []int{37} } func (x *ClusterConfig) GetRotationThreshold() uint64 { @@ -4032,7 +4179,7 @@ type PersistedClusterState struct { func (x *PersistedClusterState) Reset() { *x = PersistedClusterState{} - mi := &file_common_proto_msgTypes[36] + mi := &file_common_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4044,7 +4191,7 @@ func (x *PersistedClusterState) String() string { func (*PersistedClusterState) ProtoMessage() {} func (x *PersistedClusterState) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[36] + mi := &file_common_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4057,7 +4204,7 @@ func (x *PersistedClusterState) ProtoReflect() protoreflect.Message { // Deprecated: Use PersistedClusterState.ProtoReflect.Descriptor instead. func (*PersistedClusterState) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{36} + return file_common_proto_rawDescGZIP(), []int{38} } func (x *PersistedClusterState) GetConfig() *ClusterConfig { @@ -4084,7 +4231,7 @@ type SetChapterScheduleLog struct { func (x *SetChapterScheduleLog) Reset() { *x = SetChapterScheduleLog{} - mi := &file_common_proto_msgTypes[37] + mi := &file_common_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4096,7 +4243,7 @@ func (x *SetChapterScheduleLog) String() string { func (*SetChapterScheduleLog) ProtoMessage() {} func (x *SetChapterScheduleLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[37] + mi := &file_common_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4109,7 +4256,7 @@ func (x *SetChapterScheduleLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SetChapterScheduleLog.ProtoReflect.Descriptor instead. func (*SetChapterScheduleLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{37} + return file_common_proto_rawDescGZIP(), []int{39} } func (x *SetChapterScheduleLog) GetCron() string { @@ -4128,7 +4275,7 @@ type DeletedChapterScheduleLog struct { func (x *DeletedChapterScheduleLog) Reset() { *x = DeletedChapterScheduleLog{} - mi := &file_common_proto_msgTypes[38] + mi := &file_common_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4140,7 +4287,7 @@ func (x *DeletedChapterScheduleLog) String() string { func (*DeletedChapterScheduleLog) ProtoMessage() {} func (x *DeletedChapterScheduleLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[38] + mi := &file_common_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4153,7 +4300,7 @@ func (x *DeletedChapterScheduleLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedChapterScheduleLog.ProtoReflect.Descriptor instead. func (*DeletedChapterScheduleLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{38} + return file_common_proto_rawDescGZIP(), []int{40} } // CreatedPreparedQueryLog records the creation of a prepared query. @@ -4167,7 +4314,7 @@ type CreatedPreparedQueryLog struct { func (x *CreatedPreparedQueryLog) Reset() { *x = CreatedPreparedQueryLog{} - mi := &file_common_proto_msgTypes[39] + mi := &file_common_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4179,7 +4326,7 @@ func (x *CreatedPreparedQueryLog) String() string { func (*CreatedPreparedQueryLog) ProtoMessage() {} func (x *CreatedPreparedQueryLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[39] + mi := &file_common_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4192,7 +4339,7 @@ func (x *CreatedPreparedQueryLog) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedPreparedQueryLog.ProtoReflect.Descriptor instead. func (*CreatedPreparedQueryLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{39} + return file_common_proto_rawDescGZIP(), []int{41} } func (x *CreatedPreparedQueryLog) GetLedger() string { @@ -4222,7 +4369,7 @@ type UpdatedPreparedQueryLog struct { func (x *UpdatedPreparedQueryLog) Reset() { *x = UpdatedPreparedQueryLog{} - mi := &file_common_proto_msgTypes[40] + mi := &file_common_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4234,7 +4381,7 @@ func (x *UpdatedPreparedQueryLog) String() string { func (*UpdatedPreparedQueryLog) ProtoMessage() {} func (x *UpdatedPreparedQueryLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[40] + mi := &file_common_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4247,7 +4394,7 @@ func (x *UpdatedPreparedQueryLog) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdatedPreparedQueryLog.ProtoReflect.Descriptor instead. func (*UpdatedPreparedQueryLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{40} + return file_common_proto_rawDescGZIP(), []int{42} } func (x *UpdatedPreparedQueryLog) GetLedger() string { @@ -4289,7 +4436,7 @@ type DeletedPreparedQueryLog struct { func (x *DeletedPreparedQueryLog) Reset() { *x = DeletedPreparedQueryLog{} - mi := &file_common_proto_msgTypes[41] + mi := &file_common_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4301,7 +4448,7 @@ func (x *DeletedPreparedQueryLog) String() string { func (*DeletedPreparedQueryLog) ProtoMessage() {} func (x *DeletedPreparedQueryLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[41] + mi := &file_common_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4314,7 +4461,7 @@ func (x *DeletedPreparedQueryLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedPreparedQueryLog.ProtoReflect.Descriptor instead. func (*DeletedPreparedQueryLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{41} + return file_common_proto_rawDescGZIP(), []int{43} } func (x *DeletedPreparedQueryLog) GetLedger() string { @@ -4342,7 +4489,7 @@ type SavedLedgerMetadataLog struct { func (x *SavedLedgerMetadataLog) Reset() { *x = SavedLedgerMetadataLog{} - mi := &file_common_proto_msgTypes[42] + mi := &file_common_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4354,7 +4501,7 @@ func (x *SavedLedgerMetadataLog) String() string { func (*SavedLedgerMetadataLog) ProtoMessage() {} func (x *SavedLedgerMetadataLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[42] + mi := &file_common_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4367,7 +4514,7 @@ func (x *SavedLedgerMetadataLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SavedLedgerMetadataLog.ProtoReflect.Descriptor instead. func (*SavedLedgerMetadataLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{42} + return file_common_proto_rawDescGZIP(), []int{44} } func (x *SavedLedgerMetadataLog) GetLedger() string { @@ -4395,7 +4542,7 @@ type DeletedLedgerMetadataLog struct { func (x *DeletedLedgerMetadataLog) Reset() { *x = DeletedLedgerMetadataLog{} - mi := &file_common_proto_msgTypes[43] + mi := &file_common_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4407,7 +4554,7 @@ func (x *DeletedLedgerMetadataLog) String() string { func (*DeletedLedgerMetadataLog) ProtoMessage() {} func (x *DeletedLedgerMetadataLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[43] + mi := &file_common_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4420,7 +4567,7 @@ func (x *DeletedLedgerMetadataLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedLedgerMetadataLog.ProtoReflect.Descriptor instead. func (*DeletedLedgerMetadataLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{43} + return file_common_proto_rawDescGZIP(), []int{45} } func (x *DeletedLedgerMetadataLog) GetLedger() string { @@ -4451,7 +4598,7 @@ type NumscriptInfo struct { func (x *NumscriptInfo) Reset() { *x = NumscriptInfo{} - mi := &file_common_proto_msgTypes[44] + mi := &file_common_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4463,7 +4610,7 @@ func (x *NumscriptInfo) String() string { func (*NumscriptInfo) ProtoMessage() {} func (x *NumscriptInfo) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[44] + mi := &file_common_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4476,7 +4623,7 @@ func (x *NumscriptInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use NumscriptInfo.ProtoReflect.Descriptor instead. func (*NumscriptInfo) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{44} + return file_common_proto_rawDescGZIP(), []int{46} } func (x *NumscriptInfo) GetName() string { @@ -4524,7 +4671,7 @@ type SavedNumscriptLog struct { func (x *SavedNumscriptLog) Reset() { *x = SavedNumscriptLog{} - mi := &file_common_proto_msgTypes[45] + mi := &file_common_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4536,7 +4683,7 @@ func (x *SavedNumscriptLog) String() string { func (*SavedNumscriptLog) ProtoMessage() {} func (x *SavedNumscriptLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[45] + mi := &file_common_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4549,7 +4696,7 @@ func (x *SavedNumscriptLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SavedNumscriptLog.ProtoReflect.Descriptor instead. func (*SavedNumscriptLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{45} + return file_common_proto_rawDescGZIP(), []int{47} } func (x *SavedNumscriptLog) GetInfo() *NumscriptInfo { @@ -4570,7 +4717,7 @@ type DeletedNumscriptLog struct { func (x *DeletedNumscriptLog) Reset() { *x = DeletedNumscriptLog{} - mi := &file_common_proto_msgTypes[46] + mi := &file_common_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4582,7 +4729,7 @@ func (x *DeletedNumscriptLog) String() string { func (*DeletedNumscriptLog) ProtoMessage() {} func (x *DeletedNumscriptLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[46] + mi := &file_common_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4595,7 +4742,7 @@ func (x *DeletedNumscriptLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedNumscriptLog.ProtoReflect.Descriptor instead. func (*DeletedNumscriptLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{46} + return file_common_proto_rawDescGZIP(), []int{48} } func (x *DeletedNumscriptLog) GetName() string { @@ -4622,7 +4769,7 @@ type SetQueryCheckpointScheduleLog struct { func (x *SetQueryCheckpointScheduleLog) Reset() { *x = SetQueryCheckpointScheduleLog{} - mi := &file_common_proto_msgTypes[47] + mi := &file_common_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4634,7 +4781,7 @@ func (x *SetQueryCheckpointScheduleLog) String() string { func (*SetQueryCheckpointScheduleLog) ProtoMessage() {} func (x *SetQueryCheckpointScheduleLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[47] + mi := &file_common_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4647,7 +4794,7 @@ func (x *SetQueryCheckpointScheduleLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SetQueryCheckpointScheduleLog.ProtoReflect.Descriptor instead. func (*SetQueryCheckpointScheduleLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{47} + return file_common_proto_rawDescGZIP(), []int{49} } func (x *SetQueryCheckpointScheduleLog) GetCron() string { @@ -4666,7 +4813,7 @@ type DeletedQueryCheckpointScheduleLog struct { func (x *DeletedQueryCheckpointScheduleLog) Reset() { *x = DeletedQueryCheckpointScheduleLog{} - mi := &file_common_proto_msgTypes[48] + mi := &file_common_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4678,7 +4825,7 @@ func (x *DeletedQueryCheckpointScheduleLog) String() string { func (*DeletedQueryCheckpointScheduleLog) ProtoMessage() {} func (x *DeletedQueryCheckpointScheduleLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[48] + mi := &file_common_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4691,7 +4838,7 @@ func (x *DeletedQueryCheckpointScheduleLog) ProtoReflect() protoreflect.Message // Deprecated: Use DeletedQueryCheckpointScheduleLog.ProtoReflect.Descriptor instead. func (*DeletedQueryCheckpointScheduleLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{48} + return file_common_proto_rawDescGZIP(), []int{50} } // CreatedQueryCheckpointLog records a query checkpoint being created. @@ -4705,7 +4852,7 @@ type CreatedQueryCheckpointLog struct { func (x *CreatedQueryCheckpointLog) Reset() { *x = CreatedQueryCheckpointLog{} - mi := &file_common_proto_msgTypes[49] + mi := &file_common_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4717,7 +4864,7 @@ func (x *CreatedQueryCheckpointLog) String() string { func (*CreatedQueryCheckpointLog) ProtoMessage() {} func (x *CreatedQueryCheckpointLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[49] + mi := &file_common_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4730,7 +4877,7 @@ func (x *CreatedQueryCheckpointLog) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedQueryCheckpointLog.ProtoReflect.Descriptor instead. func (*CreatedQueryCheckpointLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{49} + return file_common_proto_rawDescGZIP(), []int{51} } func (x *CreatedQueryCheckpointLog) GetCheckpointId() uint64 { @@ -4757,7 +4904,7 @@ type DeletedQueryCheckpointLog struct { func (x *DeletedQueryCheckpointLog) Reset() { *x = DeletedQueryCheckpointLog{} - mi := &file_common_proto_msgTypes[50] + mi := &file_common_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4769,7 +4916,7 @@ func (x *DeletedQueryCheckpointLog) String() string { func (*DeletedQueryCheckpointLog) ProtoMessage() {} func (x *DeletedQueryCheckpointLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[50] + mi := &file_common_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4782,7 +4929,7 @@ func (x *DeletedQueryCheckpointLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedQueryCheckpointLog.ProtoReflect.Descriptor instead. func (*DeletedQueryCheckpointLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{50} + return file_common_proto_rawDescGZIP(), []int{52} } func (x *DeletedQueryCheckpointLog) GetCheckpointId() uint64 { @@ -4814,7 +4961,7 @@ type SinkConfig struct { func (x *SinkConfig) Reset() { *x = SinkConfig{} - mi := &file_common_proto_msgTypes[51] + mi := &file_common_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4826,7 +4973,7 @@ func (x *SinkConfig) String() string { func (*SinkConfig) ProtoMessage() {} func (x *SinkConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[51] + mi := &file_common_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4839,7 +4986,7 @@ func (x *SinkConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use SinkConfig.ProtoReflect.Descriptor instead. func (*SinkConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{51} + return file_common_proto_rawDescGZIP(), []int{53} } func (x *SinkConfig) GetName() string { @@ -4975,7 +5122,7 @@ type SinkStatus struct { func (x *SinkStatus) Reset() { *x = SinkStatus{} - mi := &file_common_proto_msgTypes[52] + mi := &file_common_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4987,7 +5134,7 @@ func (x *SinkStatus) String() string { func (*SinkStatus) ProtoMessage() {} func (x *SinkStatus) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[52] + mi := &file_common_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5000,7 +5147,7 @@ func (x *SinkStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use SinkStatus.ProtoReflect.Descriptor instead. func (*SinkStatus) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{52} + return file_common_proto_rawDescGZIP(), []int{54} } func (x *SinkStatus) GetSinkName() string { @@ -5035,7 +5182,7 @@ type SinkError struct { func (x *SinkError) Reset() { *x = SinkError{} - mi := &file_common_proto_msgTypes[53] + mi := &file_common_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5047,7 +5194,7 @@ func (x *SinkError) String() string { func (*SinkError) ProtoMessage() {} func (x *SinkError) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[53] + mi := &file_common_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5060,7 +5207,7 @@ func (x *SinkError) ProtoReflect() protoreflect.Message { // Deprecated: Use SinkError.ProtoReflect.Descriptor instead. func (*SinkError) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{53} + return file_common_proto_rawDescGZIP(), []int{55} } func (x *SinkError) GetMessage() string { @@ -5088,7 +5235,7 @@ type NatsSinkConfig struct { func (x *NatsSinkConfig) Reset() { *x = NatsSinkConfig{} - mi := &file_common_proto_msgTypes[54] + mi := &file_common_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5100,7 +5247,7 @@ func (x *NatsSinkConfig) String() string { func (*NatsSinkConfig) ProtoMessage() {} func (x *NatsSinkConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[54] + mi := &file_common_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5113,7 +5260,7 @@ func (x *NatsSinkConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use NatsSinkConfig.ProtoReflect.Descriptor instead. func (*NatsSinkConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{54} + return file_common_proto_rawDescGZIP(), []int{56} } func (x *NatsSinkConfig) GetUrl() string { @@ -5141,7 +5288,7 @@ type ClickHouseSinkConfig struct { func (x *ClickHouseSinkConfig) Reset() { *x = ClickHouseSinkConfig{} - mi := &file_common_proto_msgTypes[55] + mi := &file_common_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5153,7 +5300,7 @@ func (x *ClickHouseSinkConfig) String() string { func (*ClickHouseSinkConfig) ProtoMessage() {} func (x *ClickHouseSinkConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[55] + mi := &file_common_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5166,7 +5313,7 @@ func (x *ClickHouseSinkConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ClickHouseSinkConfig.ProtoReflect.Descriptor instead. func (*ClickHouseSinkConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{55} + return file_common_proto_rawDescGZIP(), []int{57} } func (x *ClickHouseSinkConfig) GetDsn() string { @@ -5198,7 +5345,7 @@ type KafkaSinkConfig struct { func (x *KafkaSinkConfig) Reset() { *x = KafkaSinkConfig{} - mi := &file_common_proto_msgTypes[56] + mi := &file_common_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5210,7 +5357,7 @@ func (x *KafkaSinkConfig) String() string { func (*KafkaSinkConfig) ProtoMessage() {} func (x *KafkaSinkConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[56] + mi := &file_common_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5223,7 +5370,7 @@ func (x *KafkaSinkConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use KafkaSinkConfig.ProtoReflect.Descriptor instead. func (*KafkaSinkConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{56} + return file_common_proto_rawDescGZIP(), []int{58} } func (x *KafkaSinkConfig) GetBrokers() []string { @@ -5279,7 +5426,7 @@ type HttpSinkConfig struct { func (x *HttpSinkConfig) Reset() { *x = HttpSinkConfig{} - mi := &file_common_proto_msgTypes[57] + mi := &file_common_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5291,7 +5438,7 @@ func (x *HttpSinkConfig) String() string { func (*HttpSinkConfig) ProtoMessage() {} func (x *HttpSinkConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[57] + mi := &file_common_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5304,7 +5451,7 @@ func (x *HttpSinkConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use HttpSinkConfig.ProtoReflect.Descriptor instead. func (*HttpSinkConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{57} + return file_common_proto_rawDescGZIP(), []int{59} } func (x *HttpSinkConfig) GetEndpoint() string { @@ -5342,7 +5489,7 @@ type DatabricksSinkConfig struct { func (x *DatabricksSinkConfig) Reset() { *x = DatabricksSinkConfig{} - mi := &file_common_proto_msgTypes[58] + mi := &file_common_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5354,7 +5501,7 @@ func (x *DatabricksSinkConfig) String() string { func (*DatabricksSinkConfig) ProtoMessage() {} func (x *DatabricksSinkConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[58] + mi := &file_common_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5367,7 +5514,7 @@ func (x *DatabricksSinkConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use DatabricksSinkConfig.ProtoReflect.Descriptor instead. func (*DatabricksSinkConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{58} + return file_common_proto_rawDescGZIP(), []int{60} } func (x *DatabricksSinkConfig) GetServerHostname() string { @@ -5464,7 +5611,7 @@ type DatabricksOAuthM2M struct { func (x *DatabricksOAuthM2M) Reset() { *x = DatabricksOAuthM2M{} - mi := &file_common_proto_msgTypes[59] + mi := &file_common_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5476,7 +5623,7 @@ func (x *DatabricksOAuthM2M) String() string { func (*DatabricksOAuthM2M) ProtoMessage() {} func (x *DatabricksOAuthM2M) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[59] + mi := &file_common_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5489,7 +5636,7 @@ func (x *DatabricksOAuthM2M) ProtoReflect() protoreflect.Message { // Deprecated: Use DatabricksOAuthM2M.ProtoReflect.Descriptor instead. func (*DatabricksOAuthM2M) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{59} + return file_common_proto_rawDescGZIP(), []int{61} } func (x *DatabricksOAuthM2M) GetClientId() string { @@ -5525,7 +5672,7 @@ type CreatedLedgerLog struct { func (x *CreatedLedgerLog) Reset() { *x = CreatedLedgerLog{} - mi := &file_common_proto_msgTypes[60] + mi := &file_common_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5537,7 +5684,7 @@ func (x *CreatedLedgerLog) String() string { func (*CreatedLedgerLog) ProtoMessage() {} func (x *CreatedLedgerLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[60] + mi := &file_common_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5550,7 +5697,7 @@ func (x *CreatedLedgerLog) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedLedgerLog.ProtoReflect.Descriptor instead. func (*CreatedLedgerLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{60} + return file_common_proto_rawDescGZIP(), []int{62} } func (x *CreatedLedgerLog) GetName() string { @@ -5619,7 +5766,7 @@ type DeletedLedgerLog struct { func (x *DeletedLedgerLog) Reset() { *x = DeletedLedgerLog{} - mi := &file_common_proto_msgTypes[61] + mi := &file_common_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5631,7 +5778,7 @@ func (x *DeletedLedgerLog) String() string { func (*DeletedLedgerLog) ProtoMessage() {} func (x *DeletedLedgerLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[61] + mi := &file_common_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5644,7 +5791,7 @@ func (x *DeletedLedgerLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedLedgerLog.ProtoReflect.Descriptor instead. func (*DeletedLedgerLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{61} + return file_common_proto_rawDescGZIP(), []int{63} } func (x *DeletedLedgerLog) GetName() string { @@ -5671,7 +5818,7 @@ type ApplyLedgerLog struct { func (x *ApplyLedgerLog) Reset() { *x = ApplyLedgerLog{} - mi := &file_common_proto_msgTypes[62] + mi := &file_common_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5683,7 +5830,7 @@ func (x *ApplyLedgerLog) String() string { func (*ApplyLedgerLog) ProtoMessage() {} func (x *ApplyLedgerLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[62] + mi := &file_common_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5696,7 +5843,7 @@ func (x *ApplyLedgerLog) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyLedgerLog.ProtoReflect.Descriptor instead. func (*ApplyLedgerLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{62} + return file_common_proto_rawDescGZIP(), []int{64} } func (x *ApplyLedgerLog) GetLedgerName() string { @@ -5734,7 +5881,7 @@ type LedgerLog struct { func (x *LedgerLog) Reset() { *x = LedgerLog{} - mi := &file_common_proto_msgTypes[63] + mi := &file_common_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5746,7 +5893,7 @@ func (x *LedgerLog) String() string { func (*LedgerLog) ProtoMessage() {} func (x *LedgerLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[63] + mi := &file_common_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5759,7 +5906,7 @@ func (x *LedgerLog) ProtoReflect() protoreflect.Message { // Deprecated: Use LedgerLog.ProtoReflect.Descriptor instead. func (*LedgerLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{63} + return file_common_proto_rawDescGZIP(), []int{65} } func (x *LedgerLog) GetData() *LedgerLogPayload { @@ -5791,19 +5938,22 @@ func (x *LedgerLog) GetPurgedVolumes() []*TouchedVolume { } // TouchedVolume identifies a (ledger-local) volume cell — an account paired -// with an asset. Used in transient/purged volume exclusion sets where the -// indexer must distinguish per-asset state inside a multi-asset account. +// with an asset and a color. Used in transient/purged volume exclusion sets +// where the indexer must distinguish per-(asset, color) state inside a +// multi-bucket account. The empty color is the uncolored bucket and is itself +// just another segregated cell. type TouchedVolume struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Asset string `protobuf:"bytes,2,opt,name=asset,proto3" json:"asset,omitempty"` + Color string `protobuf:"bytes,3,opt,name=color,proto3" json:"color,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TouchedVolume) Reset() { *x = TouchedVolume{} - mi := &file_common_proto_msgTypes[64] + mi := &file_common_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5815,7 +5965,7 @@ func (x *TouchedVolume) String() string { func (*TouchedVolume) ProtoMessage() {} func (x *TouchedVolume) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[64] + mi := &file_common_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5828,7 +5978,7 @@ func (x *TouchedVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use TouchedVolume.ProtoReflect.Descriptor instead. func (*TouchedVolume) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{64} + return file_common_proto_rawDescGZIP(), []int{66} } func (x *TouchedVolume) GetAccount() string { @@ -5845,6 +5995,13 @@ func (x *TouchedVolume) GetAsset() string { return "" } +func (x *TouchedVolume) GetColor() string { + if x != nil { + return x.Color + } + return "" +} + type LedgerLogPayload struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Payload: @@ -5869,7 +6026,7 @@ type LedgerLogPayload struct { func (x *LedgerLogPayload) Reset() { *x = LedgerLogPayload{} - mi := &file_common_proto_msgTypes[65] + mi := &file_common_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5881,7 +6038,7 @@ func (x *LedgerLogPayload) String() string { func (*LedgerLogPayload) ProtoMessage() {} func (x *LedgerLogPayload) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[65] + mi := &file_common_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5894,7 +6051,7 @@ func (x *LedgerLogPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use LedgerLogPayload.ProtoReflect.Descriptor instead. func (*LedgerLogPayload) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{65} + return file_common_proto_rawDescGZIP(), []int{67} } func (x *LedgerLogPayload) GetPayload() isLedgerLogPayload_Payload { @@ -6127,7 +6284,7 @@ type OrderSkippedLog struct { func (x *OrderSkippedLog) Reset() { *x = OrderSkippedLog{} - mi := &file_common_proto_msgTypes[66] + mi := &file_common_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6139,7 +6296,7 @@ func (x *OrderSkippedLog) String() string { func (*OrderSkippedLog) ProtoMessage() {} func (x *OrderSkippedLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[66] + mi := &file_common_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6152,7 +6309,7 @@ func (x *OrderSkippedLog) ProtoReflect() protoreflect.Message { // Deprecated: Use OrderSkippedLog.ProtoReflect.Descriptor instead. func (*OrderSkippedLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{66} + return file_common_proto_rawDescGZIP(), []int{68} } func (x *OrderSkippedLog) GetReason() ErrorReason { @@ -6179,7 +6336,7 @@ type CreatedIndexLog struct { func (x *CreatedIndexLog) Reset() { *x = CreatedIndexLog{} - mi := &file_common_proto_msgTypes[67] + mi := &file_common_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6191,7 +6348,7 @@ func (x *CreatedIndexLog) String() string { func (*CreatedIndexLog) ProtoMessage() {} func (x *CreatedIndexLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[67] + mi := &file_common_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6204,7 +6361,7 @@ func (x *CreatedIndexLog) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedIndexLog.ProtoReflect.Descriptor instead. func (*CreatedIndexLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{67} + return file_common_proto_rawDescGZIP(), []int{69} } func (x *CreatedIndexLog) GetId() *IndexID { @@ -6224,7 +6381,7 @@ type DroppedIndexLog struct { func (x *DroppedIndexLog) Reset() { *x = DroppedIndexLog{} - mi := &file_common_proto_msgTypes[68] + mi := &file_common_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6236,7 +6393,7 @@ func (x *DroppedIndexLog) String() string { func (*DroppedIndexLog) ProtoMessage() {} func (x *DroppedIndexLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[68] + mi := &file_common_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6249,7 +6406,7 @@ func (x *DroppedIndexLog) ProtoReflect() protoreflect.Message { // Deprecated: Use DroppedIndexLog.ProtoReflect.Descriptor instead. func (*DroppedIndexLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{68} + return file_common_proto_rawDescGZIP(), []int{70} } func (x *DroppedIndexLog) GetId() *IndexID { @@ -6268,7 +6425,7 @@ type FilledGapLog struct { func (x *FilledGapLog) Reset() { *x = FilledGapLog{} - mi := &file_common_proto_msgTypes[69] + mi := &file_common_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6280,7 +6437,7 @@ func (x *FilledGapLog) String() string { func (*FilledGapLog) ProtoMessage() {} func (x *FilledGapLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[69] + mi := &file_common_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6293,7 +6450,7 @@ func (x *FilledGapLog) ProtoReflect() protoreflect.Message { // Deprecated: Use FilledGapLog.ProtoReflect.Descriptor instead. func (*FilledGapLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{69} + return file_common_proto_rawDescGZIP(), []int{71} } func (x *FilledGapLog) GetOriginalId() uint64 { @@ -6315,7 +6472,7 @@ type CreatedTransaction struct { func (x *CreatedTransaction) Reset() { *x = CreatedTransaction{} - mi := &file_common_proto_msgTypes[70] + mi := &file_common_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6327,7 +6484,7 @@ func (x *CreatedTransaction) String() string { func (*CreatedTransaction) ProtoMessage() {} func (x *CreatedTransaction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[70] + mi := &file_common_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6340,7 +6497,7 @@ func (x *CreatedTransaction) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedTransaction.ProtoReflect.Descriptor instead. func (*CreatedTransaction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{70} + return file_common_proto_rawDescGZIP(), []int{72} } func (x *CreatedTransaction) GetTransaction() *Transaction { @@ -6382,7 +6539,7 @@ type RevertedTransaction struct { func (x *RevertedTransaction) Reset() { *x = RevertedTransaction{} - mi := &file_common_proto_msgTypes[71] + mi := &file_common_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6394,7 +6551,7 @@ func (x *RevertedTransaction) String() string { func (*RevertedTransaction) ProtoMessage() {} func (x *RevertedTransaction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[71] + mi := &file_common_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6407,7 +6564,7 @@ func (x *RevertedTransaction) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertedTransaction.ProtoReflect.Descriptor instead. func (*RevertedTransaction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{71} + return file_common_proto_rawDescGZIP(), []int{73} } func (x *RevertedTransaction) GetRevertedTransactionId() uint64 { @@ -6441,7 +6598,7 @@ type SavedMetadata struct { func (x *SavedMetadata) Reset() { *x = SavedMetadata{} - mi := &file_common_proto_msgTypes[72] + mi := &file_common_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6453,7 +6610,7 @@ func (x *SavedMetadata) String() string { func (*SavedMetadata) ProtoMessage() {} func (x *SavedMetadata) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[72] + mi := &file_common_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6466,7 +6623,7 @@ func (x *SavedMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use SavedMetadata.ProtoReflect.Descriptor instead. func (*SavedMetadata) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{72} + return file_common_proto_rawDescGZIP(), []int{74} } func (x *SavedMetadata) GetTarget() *Target { @@ -6493,7 +6650,7 @@ type DeletedMetadata struct { func (x *DeletedMetadata) Reset() { *x = DeletedMetadata{} - mi := &file_common_proto_msgTypes[73] + mi := &file_common_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6505,7 +6662,7 @@ func (x *DeletedMetadata) String() string { func (*DeletedMetadata) ProtoMessage() {} func (x *DeletedMetadata) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[73] + mi := &file_common_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6518,7 +6675,7 @@ func (x *DeletedMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedMetadata.ProtoReflect.Descriptor instead. func (*DeletedMetadata) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{73} + return file_common_proto_rawDescGZIP(), []int{75} } func (x *DeletedMetadata) GetTarget() *Target { @@ -6547,7 +6704,7 @@ type SetMetadataFieldTypeLog struct { func (x *SetMetadataFieldTypeLog) Reset() { *x = SetMetadataFieldTypeLog{} - mi := &file_common_proto_msgTypes[74] + mi := &file_common_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6559,7 +6716,7 @@ func (x *SetMetadataFieldTypeLog) String() string { func (*SetMetadataFieldTypeLog) ProtoMessage() {} func (x *SetMetadataFieldTypeLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[74] + mi := &file_common_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6572,7 +6729,7 @@ func (x *SetMetadataFieldTypeLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMetadataFieldTypeLog.ProtoReflect.Descriptor instead. func (*SetMetadataFieldTypeLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{74} + return file_common_proto_rawDescGZIP(), []int{76} } func (x *SetMetadataFieldTypeLog) GetTargetType() TargetType { @@ -6610,7 +6767,7 @@ type RemovedMetadataFieldTypeLog struct { func (x *RemovedMetadataFieldTypeLog) Reset() { *x = RemovedMetadataFieldTypeLog{} - mi := &file_common_proto_msgTypes[75] + mi := &file_common_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6622,7 +6779,7 @@ func (x *RemovedMetadataFieldTypeLog) String() string { func (*RemovedMetadataFieldTypeLog) ProtoMessage() {} func (x *RemovedMetadataFieldTypeLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[75] + mi := &file_common_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6635,7 +6792,7 @@ func (x *RemovedMetadataFieldTypeLog) ProtoReflect() protoreflect.Message { // Deprecated: Use RemovedMetadataFieldTypeLog.ProtoReflect.Descriptor instead. func (*RemovedMetadataFieldTypeLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{75} + return file_common_proto_rawDescGZIP(), []int{77} } func (x *RemovedMetadataFieldTypeLog) GetTargetType() TargetType { @@ -6678,7 +6835,7 @@ type Chapter struct { func (x *Chapter) Reset() { *x = Chapter{} - mi := &file_common_proto_msgTypes[76] + mi := &file_common_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6690,7 +6847,7 @@ func (x *Chapter) String() string { func (*Chapter) ProtoMessage() {} func (x *Chapter) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[76] + mi := &file_common_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6703,7 +6860,7 @@ func (x *Chapter) ProtoReflect() protoreflect.Message { // Deprecated: Use Chapter.ProtoReflect.Descriptor instead. func (*Chapter) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{76} + return file_common_proto_rawDescGZIP(), []int{78} } func (x *Chapter) GetId() uint64 { @@ -6793,7 +6950,7 @@ type ClosedChapterLog struct { func (x *ClosedChapterLog) Reset() { *x = ClosedChapterLog{} - mi := &file_common_proto_msgTypes[77] + mi := &file_common_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6805,7 +6962,7 @@ func (x *ClosedChapterLog) String() string { func (*ClosedChapterLog) ProtoMessage() {} func (x *ClosedChapterLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[77] + mi := &file_common_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6818,7 +6975,7 @@ func (x *ClosedChapterLog) ProtoReflect() protoreflect.Message { // Deprecated: Use ClosedChapterLog.ProtoReflect.Descriptor instead. func (*ClosedChapterLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{77} + return file_common_proto_rawDescGZIP(), []int{79} } func (x *ClosedChapterLog) GetClosedChapter() *Chapter { @@ -6844,7 +7001,7 @@ type SealedChapterLog struct { func (x *SealedChapterLog) Reset() { *x = SealedChapterLog{} - mi := &file_common_proto_msgTypes[78] + mi := &file_common_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6856,7 +7013,7 @@ func (x *SealedChapterLog) String() string { func (*SealedChapterLog) ProtoMessage() {} func (x *SealedChapterLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[78] + mi := &file_common_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6869,7 +7026,7 @@ func (x *SealedChapterLog) ProtoReflect() protoreflect.Message { // Deprecated: Use SealedChapterLog.ProtoReflect.Descriptor instead. func (*SealedChapterLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{78} + return file_common_proto_rawDescGZIP(), []int{80} } func (x *SealedChapterLog) GetChapter() *Chapter { @@ -6888,7 +7045,7 @@ type ArchivedChapterLog struct { func (x *ArchivedChapterLog) Reset() { *x = ArchivedChapterLog{} - mi := &file_common_proto_msgTypes[79] + mi := &file_common_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6900,7 +7057,7 @@ func (x *ArchivedChapterLog) String() string { func (*ArchivedChapterLog) ProtoMessage() {} func (x *ArchivedChapterLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[79] + mi := &file_common_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6913,7 +7070,7 @@ func (x *ArchivedChapterLog) ProtoReflect() protoreflect.Message { // Deprecated: Use ArchivedChapterLog.ProtoReflect.Descriptor instead. func (*ArchivedChapterLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{79} + return file_common_proto_rawDescGZIP(), []int{81} } func (x *ArchivedChapterLog) GetChapter() *Chapter { @@ -6932,7 +7089,7 @@ type ConfirmedArchiveChapterLog struct { func (x *ConfirmedArchiveChapterLog) Reset() { *x = ConfirmedArchiveChapterLog{} - mi := &file_common_proto_msgTypes[80] + mi := &file_common_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6944,7 +7101,7 @@ func (x *ConfirmedArchiveChapterLog) String() string { func (*ConfirmedArchiveChapterLog) ProtoMessage() {} func (x *ConfirmedArchiveChapterLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[80] + mi := &file_common_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6957,7 +7114,7 @@ func (x *ConfirmedArchiveChapterLog) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfirmedArchiveChapterLog.ProtoReflect.Descriptor instead. func (*ConfirmedArchiveChapterLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{80} + return file_common_proto_rawDescGZIP(), []int{82} } func (x *ConfirmedArchiveChapterLog) GetChapter() *Chapter { @@ -6986,7 +7143,7 @@ type MirrorSourceConfig struct { func (x *MirrorSourceConfig) Reset() { *x = MirrorSourceConfig{} - mi := &file_common_proto_msgTypes[81] + mi := &file_common_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6998,7 +7155,7 @@ func (x *MirrorSourceConfig) String() string { func (*MirrorSourceConfig) ProtoMessage() {} func (x *MirrorSourceConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[81] + mi := &file_common_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7011,7 +7168,7 @@ func (x *MirrorSourceConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use MirrorSourceConfig.ProtoReflect.Descriptor instead. func (*MirrorSourceConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{81} + return file_common_proto_rawDescGZIP(), []int{83} } func (x *MirrorSourceConfig) GetLedgerName() string { @@ -7109,7 +7266,7 @@ type MirrorRewriteRule struct { func (x *MirrorRewriteRule) Reset() { *x = MirrorRewriteRule{} - mi := &file_common_proto_msgTypes[82] + mi := &file_common_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7121,7 +7278,7 @@ func (x *MirrorRewriteRule) String() string { func (*MirrorRewriteRule) ProtoMessage() {} func (x *MirrorRewriteRule) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[82] + mi := &file_common_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7134,7 +7291,7 @@ func (x *MirrorRewriteRule) ProtoReflect() protoreflect.Message { // Deprecated: Use MirrorRewriteRule.ProtoReflect.Descriptor instead. func (*MirrorRewriteRule) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{82} + return file_common_proto_rawDescGZIP(), []int{84} } func (x *MirrorRewriteRule) GetScope() isMirrorRewriteRule_Scope { @@ -7240,7 +7397,7 @@ type CreatedTransactionRule struct { func (x *CreatedTransactionRule) Reset() { *x = CreatedTransactionRule{} - mi := &file_common_proto_msgTypes[83] + mi := &file_common_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7252,7 +7409,7 @@ func (x *CreatedTransactionRule) String() string { func (*CreatedTransactionRule) ProtoMessage() {} func (x *CreatedTransactionRule) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[83] + mi := &file_common_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7265,7 +7422,7 @@ func (x *CreatedTransactionRule) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedTransactionRule.ProtoReflect.Descriptor instead. func (*CreatedTransactionRule) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{83} + return file_common_proto_rawDescGZIP(), []int{85} } func (x *CreatedTransactionRule) GetMatch() string { @@ -7292,7 +7449,7 @@ type RevertedTransactionRule struct { func (x *RevertedTransactionRule) Reset() { *x = RevertedTransactionRule{} - mi := &file_common_proto_msgTypes[84] + mi := &file_common_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7304,7 +7461,7 @@ func (x *RevertedTransactionRule) String() string { func (*RevertedTransactionRule) ProtoMessage() {} func (x *RevertedTransactionRule) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[84] + mi := &file_common_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7317,7 +7474,7 @@ func (x *RevertedTransactionRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertedTransactionRule.ProtoReflect.Descriptor instead. func (*RevertedTransactionRule) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{84} + return file_common_proto_rawDescGZIP(), []int{86} } func (x *RevertedTransactionRule) GetMatch() string { @@ -7344,7 +7501,7 @@ type SavedMetadataRule struct { func (x *SavedMetadataRule) Reset() { *x = SavedMetadataRule{} - mi := &file_common_proto_msgTypes[85] + mi := &file_common_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7356,7 +7513,7 @@ func (x *SavedMetadataRule) String() string { func (*SavedMetadataRule) ProtoMessage() {} func (x *SavedMetadataRule) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[85] + mi := &file_common_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7369,7 +7526,7 @@ func (x *SavedMetadataRule) ProtoReflect() protoreflect.Message { // Deprecated: Use SavedMetadataRule.ProtoReflect.Descriptor instead. func (*SavedMetadataRule) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{85} + return file_common_proto_rawDescGZIP(), []int{87} } func (x *SavedMetadataRule) GetMatch() string { @@ -7396,7 +7553,7 @@ type DeletedMetadataRule struct { func (x *DeletedMetadataRule) Reset() { *x = DeletedMetadataRule{} - mi := &file_common_proto_msgTypes[86] + mi := &file_common_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7408,7 +7565,7 @@ func (x *DeletedMetadataRule) String() string { func (*DeletedMetadataRule) ProtoMessage() {} func (x *DeletedMetadataRule) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[86] + mi := &file_common_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7421,7 +7578,7 @@ func (x *DeletedMetadataRule) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedMetadataRule.ProtoReflect.Descriptor instead. func (*DeletedMetadataRule) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{86} + return file_common_proto_rawDescGZIP(), []int{88} } func (x *DeletedMetadataRule) GetMatch() string { @@ -7452,7 +7609,7 @@ type AnyVariantRule struct { func (x *AnyVariantRule) Reset() { *x = AnyVariantRule{} - mi := &file_common_proto_msgTypes[87] + mi := &file_common_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7464,7 +7621,7 @@ func (x *AnyVariantRule) String() string { func (*AnyVariantRule) ProtoMessage() {} func (x *AnyVariantRule) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[87] + mi := &file_common_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7477,7 +7634,7 @@ func (x *AnyVariantRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AnyVariantRule.ProtoReflect.Descriptor instead. func (*AnyVariantRule) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{87} + return file_common_proto_rawDescGZIP(), []int{89} } func (x *AnyVariantRule) GetMatch() string { @@ -7512,7 +7669,7 @@ type CreatedTransactionAction struct { func (x *CreatedTransactionAction) Reset() { *x = CreatedTransactionAction{} - mi := &file_common_proto_msgTypes[88] + mi := &file_common_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7524,7 +7681,7 @@ func (x *CreatedTransactionAction) String() string { func (*CreatedTransactionAction) ProtoMessage() {} func (x *CreatedTransactionAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[88] + mi := &file_common_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7537,7 +7694,7 @@ func (x *CreatedTransactionAction) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatedTransactionAction.ProtoReflect.Descriptor instead. func (*CreatedTransactionAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{88} + return file_common_proto_rawDescGZIP(), []int{90} } func (x *CreatedTransactionAction) GetAction() isCreatedTransactionAction_Action { @@ -7671,7 +7828,7 @@ type RevertedTransactionAction struct { func (x *RevertedTransactionAction) Reset() { *x = RevertedTransactionAction{} - mi := &file_common_proto_msgTypes[89] + mi := &file_common_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7683,7 +7840,7 @@ func (x *RevertedTransactionAction) String() string { func (*RevertedTransactionAction) ProtoMessage() {} func (x *RevertedTransactionAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[89] + mi := &file_common_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7696,7 +7853,7 @@ func (x *RevertedTransactionAction) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertedTransactionAction.ProtoReflect.Descriptor instead. func (*RevertedTransactionAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{89} + return file_common_proto_rawDescGZIP(), []int{91} } func (x *RevertedTransactionAction) GetAction() isRevertedTransactionAction_Action { @@ -7785,7 +7942,7 @@ type SavedMetadataAction struct { func (x *SavedMetadataAction) Reset() { *x = SavedMetadataAction{} - mi := &file_common_proto_msgTypes[90] + mi := &file_common_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7797,7 +7954,7 @@ func (x *SavedMetadataAction) String() string { func (*SavedMetadataAction) ProtoMessage() {} func (x *SavedMetadataAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[90] + mi := &file_common_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7810,7 +7967,7 @@ func (x *SavedMetadataAction) ProtoReflect() protoreflect.Message { // Deprecated: Use SavedMetadataAction.ProtoReflect.Descriptor instead. func (*SavedMetadataAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{90} + return file_common_proto_rawDescGZIP(), []int{92} } func (x *SavedMetadataAction) GetAction() isSavedMetadataAction_Action { @@ -7897,7 +8054,7 @@ type DeletedMetadataAction struct { func (x *DeletedMetadataAction) Reset() { *x = DeletedMetadataAction{} - mi := &file_common_proto_msgTypes[91] + mi := &file_common_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7909,7 +8066,7 @@ func (x *DeletedMetadataAction) String() string { func (*DeletedMetadataAction) ProtoMessage() {} func (x *DeletedMetadataAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[91] + mi := &file_common_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7922,7 +8079,7 @@ func (x *DeletedMetadataAction) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedMetadataAction.ProtoReflect.Descriptor instead. func (*DeletedMetadataAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{91} + return file_common_proto_rawDescGZIP(), []int{93} } func (x *DeletedMetadataAction) GetAction() isDeletedMetadataAction_Action { @@ -7979,7 +8136,7 @@ type AnyVariantAction struct { func (x *AnyVariantAction) Reset() { *x = AnyVariantAction{} - mi := &file_common_proto_msgTypes[92] + mi := &file_common_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7991,7 +8148,7 @@ func (x *AnyVariantAction) String() string { func (*AnyVariantAction) ProtoMessage() {} func (x *AnyVariantAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[92] + mi := &file_common_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8004,7 +8161,7 @@ func (x *AnyVariantAction) ProtoReflect() protoreflect.Message { // Deprecated: Use AnyVariantAction.ProtoReflect.Descriptor instead. func (*AnyVariantAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{92} + return file_common_proto_rawDescGZIP(), []int{94} } func (x *AnyVariantAction) GetAction() isAnyVariantAction_Action { @@ -8058,7 +8215,7 @@ type RewriteAddressAction struct { func (x *RewriteAddressAction) Reset() { *x = RewriteAddressAction{} - mi := &file_common_proto_msgTypes[93] + mi := &file_common_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8070,7 +8227,7 @@ func (x *RewriteAddressAction) String() string { func (*RewriteAddressAction) ProtoMessage() {} func (x *RewriteAddressAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[93] + mi := &file_common_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8083,7 +8240,7 @@ func (x *RewriteAddressAction) ProtoReflect() protoreflect.Message { // Deprecated: Use RewriteAddressAction.ProtoReflect.Descriptor instead. func (*RewriteAddressAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{93} + return file_common_proto_rawDescGZIP(), []int{95} } func (x *RewriteAddressAction) GetPattern() string { @@ -8126,7 +8283,7 @@ type SetMetadataAction struct { func (x *SetMetadataAction) Reset() { *x = SetMetadataAction{} - mi := &file_common_proto_msgTypes[94] + mi := &file_common_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8138,7 +8295,7 @@ func (x *SetMetadataAction) String() string { func (*SetMetadataAction) ProtoMessage() {} func (x *SetMetadataAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[94] + mi := &file_common_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8151,7 +8308,7 @@ func (x *SetMetadataAction) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMetadataAction.ProtoReflect.Descriptor instead. func (*SetMetadataAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{94} + return file_common_proto_rawDescGZIP(), []int{96} } func (x *SetMetadataAction) GetKey() string { @@ -8218,7 +8375,7 @@ type DeleteMetadataAction struct { func (x *DeleteMetadataAction) Reset() { *x = DeleteMetadataAction{} - mi := &file_common_proto_msgTypes[95] + mi := &file_common_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8230,7 +8387,7 @@ func (x *DeleteMetadataAction) String() string { func (*DeleteMetadataAction) ProtoMessage() {} func (x *DeleteMetadataAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[95] + mi := &file_common_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8243,7 +8400,7 @@ func (x *DeleteMetadataAction) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteMetadataAction.ProtoReflect.Descriptor instead. func (*DeleteMetadataAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{95} + return file_common_proto_rawDescGZIP(), []int{97} } func (x *DeleteMetadataAction) GetKey() string { @@ -8272,7 +8429,7 @@ type SetAccountMetadataAction struct { func (x *SetAccountMetadataAction) Reset() { *x = SetAccountMetadataAction{} - mi := &file_common_proto_msgTypes[96] + mi := &file_common_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8284,7 +8441,7 @@ func (x *SetAccountMetadataAction) String() string { func (*SetAccountMetadataAction) ProtoMessage() {} func (x *SetAccountMetadataAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[96] + mi := &file_common_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8297,7 +8454,7 @@ func (x *SetAccountMetadataAction) ProtoReflect() protoreflect.Message { // Deprecated: Use SetAccountMetadataAction.ProtoReflect.Descriptor instead. func (*SetAccountMetadataAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{96} + return file_common_proto_rawDescGZIP(), []int{98} } func (x *SetAccountMetadataAction) GetAccount() string { @@ -8372,7 +8529,7 @@ type DeleteAccountMetadataAction struct { func (x *DeleteAccountMetadataAction) Reset() { *x = DeleteAccountMetadataAction{} - mi := &file_common_proto_msgTypes[97] + mi := &file_common_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8384,7 +8541,7 @@ func (x *DeleteAccountMetadataAction) String() string { func (*DeleteAccountMetadataAction) ProtoMessage() {} func (x *DeleteAccountMetadataAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[97] + mi := &file_common_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8397,7 +8554,7 @@ func (x *DeleteAccountMetadataAction) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteAccountMetadataAction.ProtoReflect.Descriptor instead. func (*DeleteAccountMetadataAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{97} + return file_common_proto_rawDescGZIP(), []int{99} } func (x *DeleteAccountMetadataAction) GetAccount() string { @@ -8430,7 +8587,7 @@ type SetAccountMetadataFromAddressAction struct { func (x *SetAccountMetadataFromAddressAction) Reset() { *x = SetAccountMetadataFromAddressAction{} - mi := &file_common_proto_msgTypes[98] + mi := &file_common_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8442,7 +8599,7 @@ func (x *SetAccountMetadataFromAddressAction) String() string { func (*SetAccountMetadataFromAddressAction) ProtoMessage() {} func (x *SetAccountMetadataFromAddressAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[98] + mi := &file_common_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8455,7 +8612,7 @@ func (x *SetAccountMetadataFromAddressAction) ProtoReflect() protoreflect.Messag // Deprecated: Use SetAccountMetadataFromAddressAction.ProtoReflect.Descriptor instead. func (*SetAccountMetadataFromAddressAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{98} + return file_common_proto_rawDescGZIP(), []int{100} } func (x *SetAccountMetadataFromAddressAction) GetPattern() string { @@ -8483,7 +8640,7 @@ type SetAccountMetadataFromAddressReplacement struct { func (x *SetAccountMetadataFromAddressReplacement) Reset() { *x = SetAccountMetadataFromAddressReplacement{} - mi := &file_common_proto_msgTypes[99] + mi := &file_common_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8495,7 +8652,7 @@ func (x *SetAccountMetadataFromAddressReplacement) String() string { func (*SetAccountMetadataFromAddressReplacement) ProtoMessage() {} func (x *SetAccountMetadataFromAddressReplacement) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[99] + mi := &file_common_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8508,7 +8665,7 @@ func (x *SetAccountMetadataFromAddressReplacement) ProtoReflect() protoreflect.M // Deprecated: Use SetAccountMetadataFromAddressReplacement.ProtoReflect.Descriptor instead. func (*SetAccountMetadataFromAddressReplacement) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{99} + return file_common_proto_rawDescGZIP(), []int{101} } func (x *SetAccountMetadataFromAddressReplacement) GetKey() string { @@ -8540,7 +8697,7 @@ type DropAction struct { func (x *DropAction) Reset() { *x = DropAction{} - mi := &file_common_proto_msgTypes[100] + mi := &file_common_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8552,7 +8709,7 @@ func (x *DropAction) String() string { func (*DropAction) ProtoMessage() {} func (x *DropAction) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[100] + mi := &file_common_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8565,7 +8722,7 @@ func (x *DropAction) ProtoReflect() protoreflect.Message { // Deprecated: Use DropAction.ProtoReflect.Descriptor instead. func (*DropAction) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{100} + return file_common_proto_rawDescGZIP(), []int{102} } type HttpMirrorSourceConfig struct { @@ -8578,7 +8735,7 @@ type HttpMirrorSourceConfig struct { func (x *HttpMirrorSourceConfig) Reset() { *x = HttpMirrorSourceConfig{} - mi := &file_common_proto_msgTypes[101] + mi := &file_common_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8590,7 +8747,7 @@ func (x *HttpMirrorSourceConfig) String() string { func (*HttpMirrorSourceConfig) ProtoMessage() {} func (x *HttpMirrorSourceConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[101] + mi := &file_common_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8603,7 +8760,7 @@ func (x *HttpMirrorSourceConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use HttpMirrorSourceConfig.ProtoReflect.Descriptor instead. func (*HttpMirrorSourceConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{101} + return file_common_proto_rawDescGZIP(), []int{103} } func (x *HttpMirrorSourceConfig) GetBaseUrl() string { @@ -8632,7 +8789,7 @@ type OAuth2ClientCredentials struct { func (x *OAuth2ClientCredentials) Reset() { *x = OAuth2ClientCredentials{} - mi := &file_common_proto_msgTypes[102] + mi := &file_common_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8644,7 +8801,7 @@ func (x *OAuth2ClientCredentials) String() string { func (*OAuth2ClientCredentials) ProtoMessage() {} func (x *OAuth2ClientCredentials) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[102] + mi := &file_common_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8657,7 +8814,7 @@ func (x *OAuth2ClientCredentials) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuth2ClientCredentials.ProtoReflect.Descriptor instead. func (*OAuth2ClientCredentials) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{102} + return file_common_proto_rawDescGZIP(), []int{104} } func (x *OAuth2ClientCredentials) GetClientId() string { @@ -8703,7 +8860,7 @@ type PostgresMirrorSourceConfig struct { func (x *PostgresMirrorSourceConfig) Reset() { *x = PostgresMirrorSourceConfig{} - mi := &file_common_proto_msgTypes[103] + mi := &file_common_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8715,7 +8872,7 @@ func (x *PostgresMirrorSourceConfig) String() string { func (*PostgresMirrorSourceConfig) ProtoMessage() {} func (x *PostgresMirrorSourceConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[103] + mi := &file_common_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8728,7 +8885,7 @@ func (x *PostgresMirrorSourceConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PostgresMirrorSourceConfig.ProtoReflect.Descriptor instead. func (*PostgresMirrorSourceConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{103} + return file_common_proto_rawDescGZIP(), []int{105} } func (x *PostgresMirrorSourceConfig) GetDsn() string { @@ -8761,7 +8918,7 @@ type PostgresAwsIamAuth struct { func (x *PostgresAwsIamAuth) Reset() { *x = PostgresAwsIamAuth{} - mi := &file_common_proto_msgTypes[104] + mi := &file_common_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8773,7 +8930,7 @@ func (x *PostgresAwsIamAuth) String() string { func (*PostgresAwsIamAuth) ProtoMessage() {} func (x *PostgresAwsIamAuth) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[104] + mi := &file_common_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8786,7 +8943,7 @@ func (x *PostgresAwsIamAuth) ProtoReflect() protoreflect.Message { // Deprecated: Use PostgresAwsIamAuth.ProtoReflect.Descriptor instead. func (*PostgresAwsIamAuth) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{104} + return file_common_proto_rawDescGZIP(), []int{106} } func (x *PostgresAwsIamAuth) GetRegion() string { @@ -8813,7 +8970,7 @@ type MirrorSyncError struct { func (x *MirrorSyncError) Reset() { *x = MirrorSyncError{} - mi := &file_common_proto_msgTypes[105] + mi := &file_common_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8825,7 +8982,7 @@ func (x *MirrorSyncError) String() string { func (*MirrorSyncError) ProtoMessage() {} func (x *MirrorSyncError) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[105] + mi := &file_common_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8838,7 +8995,7 @@ func (x *MirrorSyncError) ProtoReflect() protoreflect.Message { // Deprecated: Use MirrorSyncError.ProtoReflect.Descriptor instead. func (*MirrorSyncError) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{105} + return file_common_proto_rawDescGZIP(), []int{107} } func (x *MirrorSyncError) GetMessage() string { @@ -8868,7 +9025,7 @@ type MirrorSyncProgress struct { func (x *MirrorSyncProgress) Reset() { *x = MirrorSyncProgress{} - mi := &file_common_proto_msgTypes[106] + mi := &file_common_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8880,7 +9037,7 @@ func (x *MirrorSyncProgress) String() string { func (*MirrorSyncProgress) ProtoMessage() {} func (x *MirrorSyncProgress) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[106] + mi := &file_common_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8893,7 +9050,7 @@ func (x *MirrorSyncProgress) ProtoReflect() protoreflect.Message { // Deprecated: Use MirrorSyncProgress.ProtoReflect.Descriptor instead. func (*MirrorSyncProgress) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{106} + return file_common_proto_rawDescGZIP(), []int{108} } func (x *MirrorSyncProgress) GetState() MirrorSyncState { @@ -8954,7 +9111,7 @@ type LedgerInfo struct { func (x *LedgerInfo) Reset() { *x = LedgerInfo{} - mi := &file_common_proto_msgTypes[107] + mi := &file_common_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8966,7 +9123,7 @@ func (x *LedgerInfo) String() string { func (*LedgerInfo) ProtoMessage() {} func (x *LedgerInfo) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[107] + mi := &file_common_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8979,7 +9136,7 @@ func (x *LedgerInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use LedgerInfo.ProtoReflect.Descriptor instead. func (*LedgerInfo) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{107} + return file_common_proto_rawDescGZIP(), []int{109} } func (x *LedgerInfo) GetName() string { @@ -9070,7 +9227,7 @@ type SaveMetadataCommand struct { func (x *SaveMetadataCommand) Reset() { *x = SaveMetadataCommand{} - mi := &file_common_proto_msgTypes[108] + mi := &file_common_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9082,7 +9239,7 @@ func (x *SaveMetadataCommand) String() string { func (*SaveMetadataCommand) ProtoMessage() {} func (x *SaveMetadataCommand) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[108] + mi := &file_common_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9095,7 +9252,7 @@ func (x *SaveMetadataCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use SaveMetadataCommand.ProtoReflect.Descriptor instead. func (*SaveMetadataCommand) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{108} + return file_common_proto_rawDescGZIP(), []int{110} } func (x *SaveMetadataCommand) GetTarget() *Target { @@ -9123,7 +9280,7 @@ type DeleteMetadataCommand struct { func (x *DeleteMetadataCommand) Reset() { *x = DeleteMetadataCommand{} - mi := &file_common_proto_msgTypes[109] + mi := &file_common_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9135,7 +9292,7 @@ func (x *DeleteMetadataCommand) String() string { func (*DeleteMetadataCommand) ProtoMessage() {} func (x *DeleteMetadataCommand) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[109] + mi := &file_common_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9148,7 +9305,7 @@ func (x *DeleteMetadataCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteMetadataCommand.ProtoReflect.Descriptor instead. func (*DeleteMetadataCommand) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{109} + return file_common_proto_rawDescGZIP(), []int{111} } func (x *DeleteMetadataCommand) GetTarget() *Target { @@ -9194,7 +9351,7 @@ type TransactionState struct { func (x *TransactionState) Reset() { *x = TransactionState{} - mi := &file_common_proto_msgTypes[110] + mi := &file_common_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9206,7 +9363,7 @@ func (x *TransactionState) String() string { func (*TransactionState) ProtoMessage() {} func (x *TransactionState) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[110] + mi := &file_common_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9219,7 +9376,7 @@ func (x *TransactionState) ProtoReflect() protoreflect.Message { // Deprecated: Use TransactionState.ProtoReflect.Descriptor instead. func (*TransactionState) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{110} + return file_common_proto_rawDescGZIP(), []int{112} } func (x *TransactionState) GetCreatedByLog() uint64 { @@ -9290,7 +9447,7 @@ type IdempotencyKeyValue struct { func (x *IdempotencyKeyValue) Reset() { *x = IdempotencyKeyValue{} - mi := &file_common_proto_msgTypes[111] + mi := &file_common_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9302,7 +9459,7 @@ func (x *IdempotencyKeyValue) String() string { func (*IdempotencyKeyValue) ProtoMessage() {} func (x *IdempotencyKeyValue) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[111] + mi := &file_common_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9315,7 +9472,7 @@ func (x *IdempotencyKeyValue) ProtoReflect() protoreflect.Message { // Deprecated: Use IdempotencyKeyValue.ProtoReflect.Descriptor instead. func (*IdempotencyKeyValue) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{111} + return file_common_proto_rawDescGZIP(), []int{113} } func (x *IdempotencyKeyValue) GetFirstLogSequence() uint64 { @@ -9376,7 +9533,7 @@ type IdempotencyFailure struct { func (x *IdempotencyFailure) Reset() { *x = IdempotencyFailure{} - mi := &file_common_proto_msgTypes[112] + mi := &file_common_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9388,7 +9545,7 @@ func (x *IdempotencyFailure) String() string { func (*IdempotencyFailure) ProtoMessage() {} func (x *IdempotencyFailure) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[112] + mi := &file_common_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9401,7 +9558,7 @@ func (x *IdempotencyFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use IdempotencyFailure.ProtoReflect.Descriptor instead. func (*IdempotencyFailure) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{112} + return file_common_proto_rawDescGZIP(), []int{114} } func (x *IdempotencyFailure) GetReason() ErrorReason { @@ -9435,7 +9592,7 @@ type TransactionReferenceValue struct { func (x *TransactionReferenceValue) Reset() { *x = TransactionReferenceValue{} - mi := &file_common_proto_msgTypes[113] + mi := &file_common_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9447,7 +9604,7 @@ func (x *TransactionReferenceValue) String() string { func (*TransactionReferenceValue) ProtoMessage() {} func (x *TransactionReferenceValue) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[113] + mi := &file_common_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9460,7 +9617,7 @@ func (x *TransactionReferenceValue) ProtoReflect() protoreflect.Message { // Deprecated: Use TransactionReferenceValue.ProtoReflect.Descriptor instead. func (*TransactionReferenceValue) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{113} + return file_common_proto_rawDescGZIP(), []int{115} } func (x *TransactionReferenceValue) GetTransactionId() uint64 { @@ -9480,7 +9637,7 @@ type NumscriptVersionValue struct { func (x *NumscriptVersionValue) Reset() { *x = NumscriptVersionValue{} - mi := &file_common_proto_msgTypes[114] + mi := &file_common_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9492,7 +9649,7 @@ func (x *NumscriptVersionValue) String() string { func (*NumscriptVersionValue) ProtoMessage() {} func (x *NumscriptVersionValue) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[114] + mi := &file_common_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9505,7 +9662,7 @@ func (x *NumscriptVersionValue) ProtoReflect() protoreflect.Message { // Deprecated: Use NumscriptVersionValue.ProtoReflect.Descriptor instead. func (*NumscriptVersionValue) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{114} + return file_common_proto_rawDescGZIP(), []int{116} } func (x *NumscriptVersionValue) GetVersion() string { @@ -9532,7 +9689,7 @@ type SegmentType struct { func (x *SegmentType) Reset() { *x = SegmentType{} - mi := &file_common_proto_msgTypes[115] + mi := &file_common_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9544,7 +9701,7 @@ func (x *SegmentType) String() string { func (*SegmentType) ProtoMessage() {} func (x *SegmentType) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[115] + mi := &file_common_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9557,7 +9714,7 @@ func (x *SegmentType) ProtoReflect() protoreflect.Message { // Deprecated: Use SegmentType.ProtoReflect.Descriptor instead. func (*SegmentType) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{115} + return file_common_proto_rawDescGZIP(), []int{117} } func (x *SegmentType) GetConstraint() isSegmentType_Constraint { @@ -9639,7 +9796,7 @@ type UUIDConstraint struct { func (x *UUIDConstraint) Reset() { *x = UUIDConstraint{} - mi := &file_common_proto_msgTypes[116] + mi := &file_common_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9651,7 +9808,7 @@ func (x *UUIDConstraint) String() string { func (*UUIDConstraint) ProtoMessage() {} func (x *UUIDConstraint) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[116] + mi := &file_common_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9664,7 +9821,7 @@ func (x *UUIDConstraint) ProtoReflect() protoreflect.Message { // Deprecated: Use UUIDConstraint.ProtoReflect.Descriptor instead. func (*UUIDConstraint) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{116} + return file_common_proto_rawDescGZIP(), []int{118} } type Uint64Constraint struct { @@ -9675,7 +9832,7 @@ type Uint64Constraint struct { func (x *Uint64Constraint) Reset() { *x = Uint64Constraint{} - mi := &file_common_proto_msgTypes[117] + mi := &file_common_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9687,7 +9844,7 @@ func (x *Uint64Constraint) String() string { func (*Uint64Constraint) ProtoMessage() {} func (x *Uint64Constraint) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[117] + mi := &file_common_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9700,7 +9857,7 @@ func (x *Uint64Constraint) ProtoReflect() protoreflect.Message { // Deprecated: Use Uint64Constraint.ProtoReflect.Descriptor instead. func (*Uint64Constraint) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{117} + return file_common_proto_rawDescGZIP(), []int{119} } type BytesConstraint struct { @@ -9711,7 +9868,7 @@ type BytesConstraint struct { func (x *BytesConstraint) Reset() { *x = BytesConstraint{} - mi := &file_common_proto_msgTypes[118] + mi := &file_common_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9723,7 +9880,7 @@ func (x *BytesConstraint) String() string { func (*BytesConstraint) ProtoMessage() {} func (x *BytesConstraint) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[118] + mi := &file_common_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9736,7 +9893,7 @@ func (x *BytesConstraint) ProtoReflect() protoreflect.Message { // Deprecated: Use BytesConstraint.ProtoReflect.Descriptor instead. func (*BytesConstraint) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{118} + return file_common_proto_rawDescGZIP(), []int{120} } // AccountType defines a single account address pattern for a ledger. @@ -9752,7 +9909,7 @@ type AccountType struct { func (x *AccountType) Reset() { *x = AccountType{} - mi := &file_common_proto_msgTypes[119] + mi := &file_common_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9764,7 +9921,7 @@ func (x *AccountType) String() string { func (*AccountType) ProtoMessage() {} func (x *AccountType) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[119] + mi := &file_common_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9777,7 +9934,7 @@ func (x *AccountType) ProtoReflect() protoreflect.Message { // Deprecated: Use AccountType.ProtoReflect.Descriptor instead. func (*AccountType) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{119} + return file_common_proto_rawDescGZIP(), []int{121} } func (x *AccountType) GetName() string { @@ -9818,7 +9975,7 @@ type AddedAccountTypeLog struct { func (x *AddedAccountTypeLog) Reset() { *x = AddedAccountTypeLog{} - mi := &file_common_proto_msgTypes[120] + mi := &file_common_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9830,7 +9987,7 @@ func (x *AddedAccountTypeLog) String() string { func (*AddedAccountTypeLog) ProtoMessage() {} func (x *AddedAccountTypeLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[120] + mi := &file_common_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9843,7 +10000,7 @@ func (x *AddedAccountTypeLog) ProtoReflect() protoreflect.Message { // Deprecated: Use AddedAccountTypeLog.ProtoReflect.Descriptor instead. func (*AddedAccountTypeLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{120} + return file_common_proto_rawDescGZIP(), []int{122} } func (x *AddedAccountTypeLog) GetAccountType() *AccountType { @@ -9863,7 +10020,7 @@ type RemovedAccountTypeLog struct { func (x *RemovedAccountTypeLog) Reset() { *x = RemovedAccountTypeLog{} - mi := &file_common_proto_msgTypes[121] + mi := &file_common_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9875,7 +10032,7 @@ func (x *RemovedAccountTypeLog) String() string { func (*RemovedAccountTypeLog) ProtoMessage() {} func (x *RemovedAccountTypeLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[121] + mi := &file_common_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9888,7 +10045,7 @@ func (x *RemovedAccountTypeLog) ProtoReflect() protoreflect.Message { // Deprecated: Use RemovedAccountTypeLog.ProtoReflect.Descriptor instead. func (*RemovedAccountTypeLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{121} + return file_common_proto_rawDescGZIP(), []int{123} } func (x *RemovedAccountTypeLog) GetName() string { @@ -9908,7 +10065,7 @@ type UpdatedDefaultEnforcementModeLog struct { func (x *UpdatedDefaultEnforcementModeLog) Reset() { *x = UpdatedDefaultEnforcementModeLog{} - mi := &file_common_proto_msgTypes[122] + mi := &file_common_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9920,7 +10077,7 @@ func (x *UpdatedDefaultEnforcementModeLog) String() string { func (*UpdatedDefaultEnforcementModeLog) ProtoMessage() {} func (x *UpdatedDefaultEnforcementModeLog) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[122] + mi := &file_common_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9933,7 +10090,7 @@ func (x *UpdatedDefaultEnforcementModeLog) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdatedDefaultEnforcementModeLog.ProtoReflect.Descriptor instead. func (*UpdatedDefaultEnforcementModeLog) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{122} + return file_common_proto_rawDescGZIP(), []int{124} } func (x *UpdatedDefaultEnforcementModeLog) GetEnforcementMode() ChartEnforcementMode { @@ -9967,7 +10124,7 @@ type QueryFilter struct { func (x *QueryFilter) Reset() { *x = QueryFilter{} - mi := &file_common_proto_msgTypes[123] + mi := &file_common_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9979,7 +10136,7 @@ func (x *QueryFilter) String() string { func (*QueryFilter) ProtoMessage() {} func (x *QueryFilter) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[123] + mi := &file_common_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9992,7 +10149,7 @@ func (x *QueryFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryFilter.ProtoReflect.Descriptor instead. func (*QueryFilter) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{123} + return file_common_proto_rawDescGZIP(), []int{125} } func (x *QueryFilter) GetFilter() isQueryFilter_Filter { @@ -10244,7 +10401,7 @@ type ReferenceCondition struct { func (x *ReferenceCondition) Reset() { *x = ReferenceCondition{} - mi := &file_common_proto_msgTypes[124] + mi := &file_common_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10256,7 +10413,7 @@ func (x *ReferenceCondition) String() string { func (*ReferenceCondition) ProtoMessage() {} func (x *ReferenceCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[124] + mi := &file_common_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10269,7 +10426,7 @@ func (x *ReferenceCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use ReferenceCondition.ProtoReflect.Descriptor instead. func (*ReferenceCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{124} + return file_common_proto_rawDescGZIP(), []int{126} } func (x *ReferenceCondition) GetCond() *StringCondition { @@ -10291,7 +10448,7 @@ type RevertedCondition struct { func (x *RevertedCondition) Reset() { *x = RevertedCondition{} - mi := &file_common_proto_msgTypes[125] + mi := &file_common_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10303,7 +10460,7 @@ func (x *RevertedCondition) String() string { func (*RevertedCondition) ProtoMessage() {} func (x *RevertedCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[125] + mi := &file_common_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10316,7 +10473,7 @@ func (x *RevertedCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertedCondition.ProtoReflect.Descriptor instead. func (*RevertedCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{125} + return file_common_proto_rawDescGZIP(), []int{127} } func (x *RevertedCondition) GetValue() bool { @@ -10358,7 +10515,7 @@ type AuditCondition struct { func (x *AuditCondition) Reset() { *x = AuditCondition{} - mi := &file_common_proto_msgTypes[126] + mi := &file_common_proto_msgTypes[128] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10370,7 +10527,7 @@ func (x *AuditCondition) String() string { func (*AuditCondition) ProtoMessage() {} func (x *AuditCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[126] + mi := &file_common_proto_msgTypes[128] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10383,7 +10540,7 @@ func (x *AuditCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use AuditCondition.ProtoReflect.Descriptor instead. func (*AuditCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{126} + return file_common_proto_rawDescGZIP(), []int{128} } func (x *AuditCondition) GetField() AuditField { @@ -10444,7 +10601,7 @@ type LedgerCondition struct { func (x *LedgerCondition) Reset() { *x = LedgerCondition{} - mi := &file_common_proto_msgTypes[127] + mi := &file_common_proto_msgTypes[129] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10456,7 +10613,7 @@ func (x *LedgerCondition) String() string { func (*LedgerCondition) ProtoMessage() {} func (x *LedgerCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[127] + mi := &file_common_proto_msgTypes[129] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10469,7 +10626,7 @@ func (x *LedgerCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use LedgerCondition.ProtoReflect.Descriptor instead. func (*LedgerCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{127} + return file_common_proto_rawDescGZIP(), []int{129} } func (x *LedgerCondition) GetCond() *StringCondition { @@ -10489,7 +10646,7 @@ type LogIdCondition struct { func (x *LogIdCondition) Reset() { *x = LogIdCondition{} - mi := &file_common_proto_msgTypes[128] + mi := &file_common_proto_msgTypes[130] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10501,7 +10658,7 @@ func (x *LogIdCondition) String() string { func (*LogIdCondition) ProtoMessage() {} func (x *LogIdCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[128] + mi := &file_common_proto_msgTypes[130] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10514,7 +10671,7 @@ func (x *LogIdCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use LogIdCondition.ProtoReflect.Descriptor instead. func (*LogIdCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{128} + return file_common_proto_rawDescGZIP(), []int{130} } func (x *LogIdCondition) GetCond() *UintCondition { @@ -10535,7 +10692,7 @@ type BuiltinUintCondition struct { func (x *BuiltinUintCondition) Reset() { *x = BuiltinUintCondition{} - mi := &file_common_proto_msgTypes[129] + mi := &file_common_proto_msgTypes[131] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10547,7 +10704,7 @@ func (x *BuiltinUintCondition) String() string { func (*BuiltinUintCondition) ProtoMessage() {} func (x *BuiltinUintCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[129] + mi := &file_common_proto_msgTypes[131] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10560,7 +10717,7 @@ func (x *BuiltinUintCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use BuiltinUintCondition.ProtoReflect.Descriptor instead. func (*BuiltinUintCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{129} + return file_common_proto_rawDescGZIP(), []int{131} } func (x *BuiltinUintCondition) GetField() TransactionBuiltinIndex { @@ -10588,7 +10745,7 @@ type LogBuiltinUintCondition struct { func (x *LogBuiltinUintCondition) Reset() { *x = LogBuiltinUintCondition{} - mi := &file_common_proto_msgTypes[130] + mi := &file_common_proto_msgTypes[132] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10600,7 +10757,7 @@ func (x *LogBuiltinUintCondition) String() string { func (*LogBuiltinUintCondition) ProtoMessage() {} func (x *LogBuiltinUintCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[130] + mi := &file_common_proto_msgTypes[132] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10613,7 +10770,7 @@ func (x *LogBuiltinUintCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use LogBuiltinUintCondition.ProtoReflect.Descriptor instead. func (*LogBuiltinUintCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{130} + return file_common_proto_rawDescGZIP(), []int{132} } func (x *LogBuiltinUintCondition) GetField() LogBuiltinIndex { @@ -10644,7 +10801,7 @@ type AccountHasAssetCondition struct { func (x *AccountHasAssetCondition) Reset() { *x = AccountHasAssetCondition{} - mi := &file_common_proto_msgTypes[131] + mi := &file_common_proto_msgTypes[133] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10656,7 +10813,7 @@ func (x *AccountHasAssetCondition) String() string { func (*AccountHasAssetCondition) ProtoMessage() {} func (x *AccountHasAssetCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[131] + mi := &file_common_proto_msgTypes[133] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10669,7 +10826,7 @@ func (x *AccountHasAssetCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use AccountHasAssetCondition.ProtoReflect.Descriptor instead. func (*AccountHasAssetCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{131} + return file_common_proto_rawDescGZIP(), []int{133} } func (x *AccountHasAssetCondition) GetAssetBase() string { @@ -10695,7 +10852,7 @@ type AndFilter struct { func (x *AndFilter) Reset() { *x = AndFilter{} - mi := &file_common_proto_msgTypes[132] + mi := &file_common_proto_msgTypes[134] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10707,7 +10864,7 @@ func (x *AndFilter) String() string { func (*AndFilter) ProtoMessage() {} func (x *AndFilter) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[132] + mi := &file_common_proto_msgTypes[134] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10720,7 +10877,7 @@ func (x *AndFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use AndFilter.ProtoReflect.Descriptor instead. func (*AndFilter) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{132} + return file_common_proto_rawDescGZIP(), []int{134} } func (x *AndFilter) GetFilters() []*QueryFilter { @@ -10739,7 +10896,7 @@ type OrFilter struct { func (x *OrFilter) Reset() { *x = OrFilter{} - mi := &file_common_proto_msgTypes[133] + mi := &file_common_proto_msgTypes[135] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10751,7 +10908,7 @@ func (x *OrFilter) String() string { func (*OrFilter) ProtoMessage() {} func (x *OrFilter) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[133] + mi := &file_common_proto_msgTypes[135] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10764,7 +10921,7 @@ func (x *OrFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use OrFilter.ProtoReflect.Descriptor instead. func (*OrFilter) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{133} + return file_common_proto_rawDescGZIP(), []int{135} } func (x *OrFilter) GetFilters() []*QueryFilter { @@ -10783,7 +10940,7 @@ type NotFilter struct { func (x *NotFilter) Reset() { *x = NotFilter{} - mi := &file_common_proto_msgTypes[134] + mi := &file_common_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10795,7 +10952,7 @@ func (x *NotFilter) String() string { func (*NotFilter) ProtoMessage() {} func (x *NotFilter) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[134] + mi := &file_common_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10808,7 +10965,7 @@ func (x *NotFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use NotFilter.ProtoReflect.Descriptor instead. func (*NotFilter) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{134} + return file_common_proto_rawDescGZIP(), []int{136} } func (x *NotFilter) GetFilter() *QueryFilter { @@ -10830,7 +10987,7 @@ type FieldRef struct { func (x *FieldRef) Reset() { *x = FieldRef{} - mi := &file_common_proto_msgTypes[135] + mi := &file_common_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10842,7 +10999,7 @@ func (x *FieldRef) String() string { func (*FieldRef) ProtoMessage() {} func (x *FieldRef) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[135] + mi := &file_common_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10855,7 +11012,7 @@ func (x *FieldRef) ProtoReflect() protoreflect.Message { // Deprecated: Use FieldRef.ProtoReflect.Descriptor instead. func (*FieldRef) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{135} + return file_common_proto_rawDescGZIP(), []int{137} } func (x *FieldRef) GetMetadata() string { @@ -10883,7 +11040,7 @@ type FieldCondition struct { func (x *FieldCondition) Reset() { *x = FieldCondition{} - mi := &file_common_proto_msgTypes[136] + mi := &file_common_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10895,7 +11052,7 @@ func (x *FieldCondition) String() string { func (*FieldCondition) ProtoMessage() {} func (x *FieldCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[136] + mi := &file_common_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10908,7 +11065,7 @@ func (x *FieldCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use FieldCondition.ProtoReflect.Descriptor instead. func (*FieldCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{136} + return file_common_proto_rawDescGZIP(), []int{138} } func (x *FieldCondition) GetField() *FieldRef { @@ -11017,7 +11174,7 @@ type StringCondition struct { func (x *StringCondition) Reset() { *x = StringCondition{} - mi := &file_common_proto_msgTypes[137] + mi := &file_common_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11029,7 +11186,7 @@ func (x *StringCondition) String() string { func (*StringCondition) ProtoMessage() {} func (x *StringCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[137] + mi := &file_common_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11042,7 +11199,7 @@ func (x *StringCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use StringCondition.ProtoReflect.Descriptor instead. func (*StringCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{137} + return file_common_proto_rawDescGZIP(), []int{139} } func (x *StringCondition) GetValue() isStringCondition_Value { @@ -11100,7 +11257,7 @@ type IntCondition struct { func (x *IntCondition) Reset() { *x = IntCondition{} - mi := &file_common_proto_msgTypes[138] + mi := &file_common_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11112,7 +11269,7 @@ func (x *IntCondition) String() string { func (*IntCondition) ProtoMessage() {} func (x *IntCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[138] + mi := &file_common_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11125,7 +11282,7 @@ func (x *IntCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use IntCondition.ProtoReflect.Descriptor instead. func (*IntCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{138} + return file_common_proto_rawDescGZIP(), []int{140} } func (x *IntCondition) GetMin() int64 { @@ -11184,7 +11341,7 @@ type UintCondition struct { func (x *UintCondition) Reset() { *x = UintCondition{} - mi := &file_common_proto_msgTypes[139] + mi := &file_common_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11196,7 +11353,7 @@ func (x *UintCondition) String() string { func (*UintCondition) ProtoMessage() {} func (x *UintCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[139] + mi := &file_common_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11209,7 +11366,7 @@ func (x *UintCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use UintCondition.ProtoReflect.Descriptor instead. func (*UintCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{139} + return file_common_proto_rawDescGZIP(), []int{141} } func (x *UintCondition) GetMin() uint64 { @@ -11267,7 +11424,7 @@ type BoolCondition struct { func (x *BoolCondition) Reset() { *x = BoolCondition{} - mi := &file_common_proto_msgTypes[140] + mi := &file_common_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11279,7 +11436,7 @@ func (x *BoolCondition) String() string { func (*BoolCondition) ProtoMessage() {} func (x *BoolCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[140] + mi := &file_common_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11292,7 +11449,7 @@ func (x *BoolCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use BoolCondition.ProtoReflect.Descriptor instead. func (*BoolCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{140} + return file_common_proto_rawDescGZIP(), []int{142} } func (x *BoolCondition) GetValue() isBoolCondition_Value { @@ -11345,7 +11502,7 @@ type ExistsCondition struct { func (x *ExistsCondition) Reset() { *x = ExistsCondition{} - mi := &file_common_proto_msgTypes[141] + mi := &file_common_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11357,7 +11514,7 @@ func (x *ExistsCondition) String() string { func (*ExistsCondition) ProtoMessage() {} func (x *ExistsCondition) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[141] + mi := &file_common_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11370,7 +11527,7 @@ func (x *ExistsCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use ExistsCondition.ProtoReflect.Descriptor instead. func (*ExistsCondition) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{141} + return file_common_proto_rawDescGZIP(), []int{143} } func (x *ExistsCondition) GetIncludeNull() bool { @@ -11396,7 +11553,7 @@ type AddressMatch struct { func (x *AddressMatch) Reset() { *x = AddressMatch{} - mi := &file_common_proto_msgTypes[142] + mi := &file_common_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11408,7 +11565,7 @@ func (x *AddressMatch) String() string { func (*AddressMatch) ProtoMessage() {} func (x *AddressMatch) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[142] + mi := &file_common_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11421,7 +11578,7 @@ func (x *AddressMatch) ProtoReflect() protoreflect.Message { // Deprecated: Use AddressMatch.ProtoReflect.Descriptor instead. func (*AddressMatch) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{142} + return file_common_proto_rawDescGZIP(), []int{144} } func (x *AddressMatch) GetMatch() isAddressMatch_Match { @@ -11517,7 +11674,7 @@ type PreparedQuery struct { func (x *PreparedQuery) Reset() { *x = PreparedQuery{} - mi := &file_common_proto_msgTypes[143] + mi := &file_common_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11529,7 +11686,7 @@ func (x *PreparedQuery) String() string { func (*PreparedQuery) ProtoMessage() {} func (x *PreparedQuery) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[143] + mi := &file_common_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11542,7 +11699,7 @@ func (x *PreparedQuery) ProtoReflect() protoreflect.Message { // Deprecated: Use PreparedQuery.ProtoReflect.Descriptor instead. func (*PreparedQuery) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{143} + return file_common_proto_rawDescGZIP(), []int{145} } func (x *PreparedQuery) GetName() string { @@ -11566,19 +11723,23 @@ func (x *PreparedQuery) GetTarget() QueryTarget { return QueryTarget_QUERY_TARGET_ACCOUNTS } -// AggregatedVolume represents per-asset aggregated input/output volumes. +// AggregatedVolume represents aggregated input/output volumes for a single +// (asset, color) bucket. When collapse_colors is requested on the aggregate +// query, all entries are produced with color = "" and amounts summed across +// colors. type AggregatedVolume struct { state protoimpl.MessageState `protogen:"open.v1"` Asset string `protobuf:"bytes,1,opt,name=asset,proto3" json:"asset,omitempty"` Input *Uint256 `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` Output *Uint256 `protobuf:"bytes,3,opt,name=output,proto3" json:"output,omitempty"` + Color string `protobuf:"bytes,4,opt,name=color,proto3" json:"color,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AggregatedVolume) Reset() { *x = AggregatedVolume{} - mi := &file_common_proto_msgTypes[144] + mi := &file_common_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11590,7 +11751,7 @@ func (x *AggregatedVolume) String() string { func (*AggregatedVolume) ProtoMessage() {} func (x *AggregatedVolume) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[144] + mi := &file_common_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11603,7 +11764,7 @@ func (x *AggregatedVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use AggregatedVolume.ProtoReflect.Descriptor instead. func (*AggregatedVolume) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{144} + return file_common_proto_rawDescGZIP(), []int{146} } func (x *AggregatedVolume) GetAsset() string { @@ -11627,6 +11788,13 @@ func (x *AggregatedVolume) GetOutput() *Uint256 { return nil } +func (x *AggregatedVolume) GetColor() string { + if x != nil { + return x.Color + } + return "" +} + type AggregateResult struct { state protoimpl.MessageState `protogen:"open.v1"` Volumes []*AggregatedVolume `protobuf:"bytes,1,rep,name=volumes,proto3" json:"volumes,omitempty"` @@ -11638,7 +11806,7 @@ type AggregateResult struct { func (x *AggregateResult) Reset() { *x = AggregateResult{} - mi := &file_common_proto_msgTypes[145] + mi := &file_common_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11650,7 +11818,7 @@ func (x *AggregateResult) String() string { func (*AggregateResult) ProtoMessage() {} func (x *AggregateResult) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[145] + mi := &file_common_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11663,7 +11831,7 @@ func (x *AggregateResult) ProtoReflect() protoreflect.Message { // Deprecated: Use AggregateResult.ProtoReflect.Descriptor instead. func (*AggregateResult) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{145} + return file_common_proto_rawDescGZIP(), []int{147} } func (x *AggregateResult) GetVolumes() []*AggregatedVolume { @@ -11691,7 +11859,7 @@ type GroupedAggregateResult struct { func (x *GroupedAggregateResult) Reset() { *x = GroupedAggregateResult{} - mi := &file_common_proto_msgTypes[146] + mi := &file_common_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11703,7 +11871,7 @@ func (x *GroupedAggregateResult) String() string { func (*GroupedAggregateResult) ProtoMessage() {} func (x *GroupedAggregateResult) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[146] + mi := &file_common_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11716,7 +11884,7 @@ func (x *GroupedAggregateResult) ProtoReflect() protoreflect.Message { // Deprecated: Use GroupedAggregateResult.ProtoReflect.Descriptor instead. func (*GroupedAggregateResult) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{146} + return file_common_proto_rawDescGZIP(), []int{148} } func (x *GroupedAggregateResult) GetPrefix() string { @@ -11749,7 +11917,7 @@ type PreparedQueryCursor struct { func (x *PreparedQueryCursor) Reset() { *x = PreparedQueryCursor{} - mi := &file_common_proto_msgTypes[147] + mi := &file_common_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11761,7 +11929,7 @@ func (x *PreparedQueryCursor) String() string { func (*PreparedQueryCursor) ProtoMessage() {} func (x *PreparedQueryCursor) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[147] + mi := &file_common_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11774,7 +11942,7 @@ func (x *PreparedQueryCursor) ProtoReflect() protoreflect.Message { // Deprecated: Use PreparedQueryCursor.ProtoReflect.Descriptor instead. func (*PreparedQueryCursor) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{147} + return file_common_proto_rawDescGZIP(), []int{149} } func (x *PreparedQueryCursor) GetPageSize() uint32 { @@ -11845,7 +12013,7 @@ type LedgerStats struct { func (x *LedgerStats) Reset() { *x = LedgerStats{} - mi := &file_common_proto_msgTypes[148] + mi := &file_common_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11857,7 +12025,7 @@ func (x *LedgerStats) String() string { func (*LedgerStats) ProtoMessage() {} func (x *LedgerStats) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[148] + mi := &file_common_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11870,7 +12038,7 @@ func (x *LedgerStats) ProtoReflect() protoreflect.Message { // Deprecated: Use LedgerStats.ProtoReflect.Descriptor instead. func (*LedgerStats) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{148} + return file_common_proto_rawDescGZIP(), []int{150} } func (x *LedgerStats) GetTransactionCount() uint64 { @@ -11958,7 +12126,7 @@ type PersistedConfig struct { func (x *PersistedConfig) Reset() { *x = PersistedConfig{} - mi := &file_common_proto_msgTypes[149] + mi := &file_common_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11970,7 +12138,7 @@ func (x *PersistedConfig) String() string { func (*PersistedConfig) ProtoMessage() {} func (x *PersistedConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[149] + mi := &file_common_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11983,7 +12151,7 @@ func (x *PersistedConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PersistedConfig.ProtoReflect.Descriptor instead. func (*PersistedConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{149} + return file_common_proto_rawDescGZIP(), []int{151} } func (x *PersistedConfig) GetNodeId() uint64 { @@ -12041,7 +12209,7 @@ type CallerIdentity struct { func (x *CallerIdentity) Reset() { *x = CallerIdentity{} - mi := &file_common_proto_msgTypes[150] + mi := &file_common_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12053,7 +12221,7 @@ func (x *CallerIdentity) String() string { func (*CallerIdentity) ProtoMessage() {} func (x *CallerIdentity) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[150] + mi := &file_common_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12066,7 +12234,7 @@ func (x *CallerIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use CallerIdentity.ProtoReflect.Descriptor instead. func (*CallerIdentity) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{150} + return file_common_proto_rawDescGZIP(), []int{152} } func (x *CallerIdentity) GetSubject() string { @@ -12153,7 +12321,7 @@ type CallerSnapshot struct { func (x *CallerSnapshot) Reset() { *x = CallerSnapshot{} - mi := &file_common_proto_msgTypes[151] + mi := &file_common_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12165,7 +12333,7 @@ func (x *CallerSnapshot) String() string { func (*CallerSnapshot) ProtoMessage() {} func (x *CallerSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[151] + mi := &file_common_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12178,7 +12346,7 @@ func (x *CallerSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use CallerSnapshot.ProtoReflect.Descriptor instead. func (*CallerSnapshot) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{151} + return file_common_proto_rawDescGZIP(), []int{153} } func (x *CallerSnapshot) GetIdentity() *CallerIdentity { @@ -12216,7 +12384,7 @@ type S3StorageConfig struct { func (x *S3StorageConfig) Reset() { *x = S3StorageConfig{} - mi := &file_common_proto_msgTypes[152] + mi := &file_common_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12228,7 +12396,7 @@ func (x *S3StorageConfig) String() string { func (*S3StorageConfig) ProtoMessage() {} func (x *S3StorageConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[152] + mi := &file_common_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12241,7 +12409,7 @@ func (x *S3StorageConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use S3StorageConfig.ProtoReflect.Descriptor instead. func (*S3StorageConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{152} + return file_common_proto_rawDescGZIP(), []int{154} } func (x *S3StorageConfig) GetBucket() string { @@ -12292,7 +12460,7 @@ type AzureStorageConfig struct { func (x *AzureStorageConfig) Reset() { *x = AzureStorageConfig{} - mi := &file_common_proto_msgTypes[153] + mi := &file_common_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12304,7 +12472,7 @@ func (x *AzureStorageConfig) String() string { func (*AzureStorageConfig) ProtoMessage() {} func (x *AzureStorageConfig) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[153] + mi := &file_common_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12317,7 +12485,7 @@ func (x *AzureStorageConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use AzureStorageConfig.ProtoReflect.Descriptor instead. func (*AzureStorageConfig) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{153} + return file_common_proto_rawDescGZIP(), []int{155} } func (x *AzureStorageConfig) GetAccountName() string { @@ -12364,7 +12532,7 @@ type BackupStorage struct { func (x *BackupStorage) Reset() { *x = BackupStorage{} - mi := &file_common_proto_msgTypes[154] + mi := &file_common_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12376,7 +12544,7 @@ func (x *BackupStorage) String() string { func (*BackupStorage) ProtoMessage() {} func (x *BackupStorage) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[154] + mi := &file_common_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12389,7 +12557,7 @@ func (x *BackupStorage) ProtoReflect() protoreflect.Message { // Deprecated: Use BackupStorage.ProtoReflect.Descriptor instead. func (*BackupStorage) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{154} + return file_common_proto_rawDescGZIP(), []int{156} } func (x *BackupStorage) GetProvider() isBackupStorage_Provider { @@ -12452,7 +12620,7 @@ type ReadOptions struct { func (x *ReadOptions) Reset() { *x = ReadOptions{} - mi := &file_common_proto_msgTypes[155] + mi := &file_common_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12464,7 +12632,7 @@ func (x *ReadOptions) String() string { func (*ReadOptions) ProtoMessage() {} func (x *ReadOptions) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[155] + mi := &file_common_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12477,7 +12645,7 @@ func (x *ReadOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadOptions.ProtoReflect.Descriptor instead. func (*ReadOptions) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{155} + return file_common_proto_rawDescGZIP(), []int{157} } func (x *ReadOptions) GetCheckpointId() uint64 { @@ -12529,7 +12697,7 @@ type ListOptions struct { func (x *ListOptions) Reset() { *x = ListOptions{} - mi := &file_common_proto_msgTypes[156] + mi := &file_common_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12541,7 +12709,7 @@ func (x *ListOptions) String() string { func (*ListOptions) ProtoMessage() {} func (x *ListOptions) ProtoReflect() protoreflect.Message { - mi := &file_common_proto_msgTypes[156] + mi := &file_common_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12554,7 +12722,7 @@ func (x *ListOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use ListOptions.ProtoReflect.Descriptor instead. func (*ListOptions) Descriptor() ([]byte, []int) { - return file_common_proto_rawDescGZIP(), []int{156} + return file_common_proto_rawDescGZIP(), []int{158} } func (x *ListOptions) GetRead() *ReadOptions { @@ -12660,12 +12828,13 @@ const file_common_proto_rawDesc = "" + "\x02v0\x18\x01 \x01(\x06R\x02v0\x12\x0e\n" + "\x02v1\x18\x02 \x01(\x06R\x02v1\x12\x0e\n" + "\x02v2\x18\x03 \x01(\x06R\x02v2\x12\x0e\n" + - "\x02v3\x18\x04 \x01(\x06R\x02v3\"\x82\x01\n" + + "\x02v3\x18\x04 \x01(\x06R\x02v3\"\x98\x01\n" + "\aPosting\x12\x16\n" + "\x06source\x18\x01 \x01(\tR\x06source\x12 \n" + "\vdestination\x18\x02 \x01(\tR\vdestination\x12'\n" + "\x06amount\x18\x03 \x01(\v2\x0f.common.Uint256R\x06amount\x12\x14\n" + - "\x05asset\x18\x04 \x01(\tR\x05asset\"\xcb\x04\n" + + "\x05asset\x18\x04 \x01(\tR\x05asset\x12\x14\n" + + "\x05color\x18\x05 \x01(\tR\x05color\"\xcb\x04\n" + "\vTransaction\x12+\n" + "\bpostings\x18\x01 \x03(\v2\x0f.common.PostingR\bpostings\x12=\n" + "\bmetadata\x18\x02 \x03(\v2!.common.Transaction.MetadataEntryR\bmetadata\x12/\n" + @@ -12698,17 +12867,22 @@ const file_common_proto_rawDesc = "" + "\x12VolumesWithBalance\x12\x14\n" + "\x05input\x18\x01 \x01(\tR\x05input\x12\x16\n" + "\x06output\x18\x02 \x01(\tR\x06output\x12\x18\n" + - "\abalance\x18\x03 \x01(\tR\abalance\"\x9e\x01\n" + - "\x0fVolumesByAssets\x12>\n" + - "\avolumes\x18\x01 \x03(\v2$.common.VolumesByAssets.VolumesEntryR\avolumes\x1aK\n" + - "\fVolumesEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12%\n" + - "\x05value\x18\x02 \x01(\v2\x0f.common.VolumesR\x05value:\x028\x01\"\xd0\x01\n" + + "\abalance\x18\x03 \x01(\tR\abalance\"@\n" + + "\x0fVolumesByAssets\x12-\n" + + "\avolumes\x18\x01 \x03(\v2\x13.common.VolumeEntryR\avolumes\"d\n" + + "\vVolumeEntry\x12\x14\n" + + "\x05asset\x18\x01 \x01(\tR\x05asset\x12\x14\n" + + "\x05color\x18\x02 \x01(\tR\x05color\x12)\n" + + "\avolumes\x18\x03 \x01(\v2\x0f.common.VolumesR\avolumes\"\xd0\x01\n" + "\x11PostCommitVolumes\x12]\n" + "\x12volumes_by_account\x18\x01 \x03(\v2/.common.PostCommitVolumes.VolumesByAccountEntryR\x10volumesByAccount\x1a\\\n" + "\x15VolumesByAccountEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12-\n" + - "\x05value\x18\x02 \x01(\v2\x17.common.VolumesByAssetsR\x05value:\x028\x01\"\xe2\x03\n" + + "\x05value\x18\x02 \x01(\v2\x17.common.VolumesByAssetsR\x05value:\x028\x01\"q\n" + + "\rAccountVolume\x12\x14\n" + + "\x05asset\x18\x01 \x01(\tR\x05asset\x12\x14\n" + + "\x05color\x18\x02 \x01(\tR\x05color\x124\n" + + "\avolumes\x18\x03 \x01(\v2\x1a.common.VolumesWithBalanceR\avolumes\"\x83\x03\n" + "\aAccount\x12\x18\n" + "\aaddress\x18\x01 \x01(\tR\aaddress\x129\n" + "\bmetadata\x18\x02 \x03(\v2\x1d.common.Account.MetadataEntryR\bmetadata\x122\n" + @@ -12716,14 +12890,11 @@ const file_common_proto_rawDesc = "" + "firstUsage\x128\n" + "\x0einsertion_date\x18\x04 \x01(\v2\x11.common.TimestampR\rinsertionDate\x120\n" + "\n" + - "updated_at\x18\x05 \x01(\v2\x11.common.TimestampR\tupdatedAt\x126\n" + - "\avolumes\x18\x06 \x03(\v2\x1c.common.Account.VolumesEntryR\avolumes\x1aR\n" + + "updated_at\x18\x05 \x01(\v2\x11.common.TimestampR\tupdatedAt\x12/\n" + + "\avolumes\x18\x06 \x03(\v2\x15.common.AccountVolumeR\avolumes\x1aR\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + - "\x05value\x18\x02 \x01(\v2\x15.common.MetadataValueR\x05value:\x028\x01\x1aV\n" + - "\fVolumesEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x120\n" + - "\x05value\x18\x02 \x01(\v2\x1a.common.VolumesWithBalanceR\x05value:\x028\x01\"#\n" + + "\x05value\x18\x02 \x01(\v2\x15.common.MetadataValueR\x05value:\x028\x01\"#\n" + "\rTargetAccount\x12\x12\n" + "\x04addr\x18\x01 \x01(\tR\x04addr\"n\n" + "\x06Target\x121\n" + @@ -12987,10 +13158,11 @@ const file_common_proto_rawDesc = "" + "\x04data\x18\x01 \x01(\v2\x18.common.LedgerLogPayloadR\x04data\x12%\n" + "\x04date\x18\x02 \x01(\v2\x11.common.TimestampR\x04date\x12\x0e\n" + "\x02id\x18\x03 \x01(\x06R\x02id\x12<\n" + - "\x0epurged_volumes\x18\x04 \x03(\v2\x15.common.TouchedVolumeR\rpurgedVolumes\"?\n" + + "\x0epurged_volumes\x18\x04 \x03(\v2\x15.common.TouchedVolumeR\rpurgedVolumes\"U\n" + "\rTouchedVolume\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12\x14\n" + - "\x05asset\x18\x02 \x01(\tR\x05asset\"\x84\b\n" + + "\x05asset\x18\x02 \x01(\tR\x05asset\x12\x14\n" + + "\x05color\x18\x03 \x01(\tR\x05color\"\x84\b\n" + "\x10LedgerLogPayload\x12M\n" + "\x13created_transaction\x18\x01 \x01(\v2\x1a.common.CreatedTransactionH\x00R\x12createdTransaction\x12P\n" + "\x14reverted_transaction\x18\x02 \x01(\v2\x1b.common.RevertedTransactionH\x00R\x13revertedTransaction\x12>\n" + @@ -13379,11 +13551,12 @@ const file_common_proto_rawDesc = "" + "\rPreparedQuery\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12+\n" + "\x06filter\x18\x02 \x01(\v2\x13.common.QueryFilterR\x06filter\x12+\n" + - "\x06target\x18\x03 \x01(\x0e2\x13.common.QueryTargetR\x06target\"x\n" + + "\x06target\x18\x03 \x01(\x0e2\x13.common.QueryTargetR\x06target\"\x8e\x01\n" + "\x10AggregatedVolume\x12\x14\n" + "\x05asset\x18\x01 \x01(\tR\x05asset\x12%\n" + "\x05input\x18\x02 \x01(\v2\x0f.common.Uint256R\x05input\x12'\n" + - "\x06output\x18\x03 \x01(\v2\x0f.common.Uint256R\x06output\"}\n" + + "\x06output\x18\x03 \x01(\v2\x0f.common.Uint256R\x06output\x12\x14\n" + + "\x05color\x18\x04 \x01(\tR\x05color\"}\n" + "\x0fAggregateResult\x122\n" + "\avolumes\x18\x01 \x03(\v2\x18.common.AggregatedVolumeR\avolumes\x126\n" + "\x06groups\x18\x02 \x03(\v2\x1e.common.GroupedAggregateResultR\x06groups\"d\n" + @@ -13513,7 +13686,7 @@ const file_common_proto_rawDesc = "" + "\x12LEDGER_MODE_MIRROR\x10\x01*Q\n" + "\x0fMirrorSyncState\x12\x1d\n" + "\x19MIRROR_SYNC_STATE_SYNCING\x10\x00\x12\x1f\n" + - "\x1bMIRROR_SYNC_STATE_FOLLOWING\x10\x01*\xd1\x14\n" + + "\x1bMIRROR_SYNC_STATE_FOLLOWING\x10\x01*\x9a\x15\n" + "\vErrorReason\x12\x1c\n" + "\x18ERROR_REASON_UNSPECIFIED\x10\x00\x12&\n" + "\"ERROR_REASON_LEDGER_ALREADY_EXISTS\x10\x01\x12!\n" + @@ -13581,7 +13754,9 @@ const file_common_proto_rawDesc = "" + "&ERROR_REASON_WRITES_BLOCKED_CLOCK_SKEW\x10>\x12%\n" + "!ERROR_REASON_CHECKPOINT_NOT_READY\x10?\x12%\n" + "!ERROR_REASON_MIRROR_V2_LOG_ID_GAP\x10@\x12)\n" + - "%ERROR_REASON_MIRROR_V2_LOG_ID_INVALID\x10A*Q\n" + + "%ERROR_REASON_MIRROR_V2_LOG_ID_INVALID\x10A\x12#\n" + + "\x1fERROR_REASON_AGGREGATE_OVERFLOW\x10B\x12\"\n" + + "\x1eERROR_REASON_BALANCE_NOT_FOUND\x10C*Q\n" + "\x14ChartEnforcementMode\x12\x1c\n" + "\x18CHART_ENFORCEMENT_STRICT\x10\x00\x12\x1b\n" + "\x17CHART_ENFORCEMENT_AUDIT\x10\x01*i\n" + @@ -13660,158 +13835,158 @@ var file_common_proto_goTypes = []any{ (*Volumes)(nil), // 27: common.Volumes (*VolumesWithBalance)(nil), // 28: common.VolumesWithBalance (*VolumesByAssets)(nil), // 29: common.VolumesByAssets - (*PostCommitVolumes)(nil), // 30: common.PostCommitVolumes - (*Account)(nil), // 31: common.Account - (*TargetAccount)(nil), // 32: common.TargetAccount - (*Target)(nil), // 33: common.Target - (*MetadataFieldSchema)(nil), // 34: common.MetadataFieldSchema - (*MetadataSchema)(nil), // 35: common.MetadataSchema - (*SetMetadataFieldTypeCommand)(nil), // 36: common.SetMetadataFieldTypeCommand - (*MetadataIndexID)(nil), // 37: common.MetadataIndexID - (*IndexID)(nil), // 38: common.IndexID - (*Index)(nil), // 39: common.Index - (*Idempotency)(nil), // 40: common.Idempotency - (*IdempotencyEntry)(nil), // 41: common.IdempotencyEntry - (*Log)(nil), // 42: common.Log - (*LogPayload)(nil), // 43: common.LogPayload - (*PromotedLedgerLog)(nil), // 44: common.PromotedLedgerLog - (*RegisteredSigningKeyLog)(nil), // 45: common.RegisteredSigningKeyLog - (*RevokedSigningKeyLog)(nil), // 46: common.RevokedSigningKeyLog - (*SigningKey)(nil), // 47: common.SigningKey - (*SetSigningConfigLog)(nil), // 48: common.SetSigningConfigLog - (*AddedEventsSinkLog)(nil), // 49: common.AddedEventsSinkLog - (*RemovedEventsSinkLog)(nil), // 50: common.RemovedEventsSinkLog - (*SetMaintenanceModeLog)(nil), // 51: common.SetMaintenanceModeLog - (*BloomTypeConfig)(nil), // 52: common.BloomTypeConfig - (*ClusterConfig)(nil), // 53: common.ClusterConfig - (*PersistedClusterState)(nil), // 54: common.PersistedClusterState - (*SetChapterScheduleLog)(nil), // 55: common.SetChapterScheduleLog - (*DeletedChapterScheduleLog)(nil), // 56: common.DeletedChapterScheduleLog - (*CreatedPreparedQueryLog)(nil), // 57: common.CreatedPreparedQueryLog - (*UpdatedPreparedQueryLog)(nil), // 58: common.UpdatedPreparedQueryLog - (*DeletedPreparedQueryLog)(nil), // 59: common.DeletedPreparedQueryLog - (*SavedLedgerMetadataLog)(nil), // 60: common.SavedLedgerMetadataLog - (*DeletedLedgerMetadataLog)(nil), // 61: common.DeletedLedgerMetadataLog - (*NumscriptInfo)(nil), // 62: common.NumscriptInfo - (*SavedNumscriptLog)(nil), // 63: common.SavedNumscriptLog - (*DeletedNumscriptLog)(nil), // 64: common.DeletedNumscriptLog - (*SetQueryCheckpointScheduleLog)(nil), // 65: common.SetQueryCheckpointScheduleLog - (*DeletedQueryCheckpointScheduleLog)(nil), // 66: common.DeletedQueryCheckpointScheduleLog - (*CreatedQueryCheckpointLog)(nil), // 67: common.CreatedQueryCheckpointLog - (*DeletedQueryCheckpointLog)(nil), // 68: common.DeletedQueryCheckpointLog - (*SinkConfig)(nil), // 69: common.SinkConfig - (*SinkStatus)(nil), // 70: common.SinkStatus - (*SinkError)(nil), // 71: common.SinkError - (*NatsSinkConfig)(nil), // 72: common.NatsSinkConfig - (*ClickHouseSinkConfig)(nil), // 73: common.ClickHouseSinkConfig - (*KafkaSinkConfig)(nil), // 74: common.KafkaSinkConfig - (*HttpSinkConfig)(nil), // 75: common.HttpSinkConfig - (*DatabricksSinkConfig)(nil), // 76: common.DatabricksSinkConfig - (*DatabricksOAuthM2M)(nil), // 77: common.DatabricksOAuthM2M - (*CreatedLedgerLog)(nil), // 78: common.CreatedLedgerLog - (*DeletedLedgerLog)(nil), // 79: common.DeletedLedgerLog - (*ApplyLedgerLog)(nil), // 80: common.ApplyLedgerLog - (*LedgerLog)(nil), // 81: common.LedgerLog - (*TouchedVolume)(nil), // 82: common.TouchedVolume - (*LedgerLogPayload)(nil), // 83: common.LedgerLogPayload - (*OrderSkippedLog)(nil), // 84: common.OrderSkippedLog - (*CreatedIndexLog)(nil), // 85: common.CreatedIndexLog - (*DroppedIndexLog)(nil), // 86: common.DroppedIndexLog - (*FilledGapLog)(nil), // 87: common.FilledGapLog - (*CreatedTransaction)(nil), // 88: common.CreatedTransaction - (*RevertedTransaction)(nil), // 89: common.RevertedTransaction - (*SavedMetadata)(nil), // 90: common.SavedMetadata - (*DeletedMetadata)(nil), // 91: common.DeletedMetadata - (*SetMetadataFieldTypeLog)(nil), // 92: common.SetMetadataFieldTypeLog - (*RemovedMetadataFieldTypeLog)(nil), // 93: common.RemovedMetadataFieldTypeLog - (*Chapter)(nil), // 94: common.Chapter - (*ClosedChapterLog)(nil), // 95: common.ClosedChapterLog - (*SealedChapterLog)(nil), // 96: common.SealedChapterLog - (*ArchivedChapterLog)(nil), // 97: common.ArchivedChapterLog - (*ConfirmedArchiveChapterLog)(nil), // 98: common.ConfirmedArchiveChapterLog - (*MirrorSourceConfig)(nil), // 99: common.MirrorSourceConfig - (*MirrorRewriteRule)(nil), // 100: common.MirrorRewriteRule - (*CreatedTransactionRule)(nil), // 101: common.CreatedTransactionRule - (*RevertedTransactionRule)(nil), // 102: common.RevertedTransactionRule - (*SavedMetadataRule)(nil), // 103: common.SavedMetadataRule - (*DeletedMetadataRule)(nil), // 104: common.DeletedMetadataRule - (*AnyVariantRule)(nil), // 105: common.AnyVariantRule - (*CreatedTransactionAction)(nil), // 106: common.CreatedTransactionAction - (*RevertedTransactionAction)(nil), // 107: common.RevertedTransactionAction - (*SavedMetadataAction)(nil), // 108: common.SavedMetadataAction - (*DeletedMetadataAction)(nil), // 109: common.DeletedMetadataAction - (*AnyVariantAction)(nil), // 110: common.AnyVariantAction - (*RewriteAddressAction)(nil), // 111: common.RewriteAddressAction - (*SetMetadataAction)(nil), // 112: common.SetMetadataAction - (*DeleteMetadataAction)(nil), // 113: common.DeleteMetadataAction - (*SetAccountMetadataAction)(nil), // 114: common.SetAccountMetadataAction - (*DeleteAccountMetadataAction)(nil), // 115: common.DeleteAccountMetadataAction - (*SetAccountMetadataFromAddressAction)(nil), // 116: common.SetAccountMetadataFromAddressAction - (*SetAccountMetadataFromAddressReplacement)(nil), // 117: common.SetAccountMetadataFromAddressReplacement - (*DropAction)(nil), // 118: common.DropAction - (*HttpMirrorSourceConfig)(nil), // 119: common.HttpMirrorSourceConfig - (*OAuth2ClientCredentials)(nil), // 120: common.OAuth2ClientCredentials - (*PostgresMirrorSourceConfig)(nil), // 121: common.PostgresMirrorSourceConfig - (*PostgresAwsIamAuth)(nil), // 122: common.PostgresAwsIamAuth - (*MirrorSyncError)(nil), // 123: common.MirrorSyncError - (*MirrorSyncProgress)(nil), // 124: common.MirrorSyncProgress - (*LedgerInfo)(nil), // 125: common.LedgerInfo - (*SaveMetadataCommand)(nil), // 126: common.SaveMetadataCommand - (*DeleteMetadataCommand)(nil), // 127: common.DeleteMetadataCommand - (*TransactionState)(nil), // 128: common.TransactionState - (*IdempotencyKeyValue)(nil), // 129: common.IdempotencyKeyValue - (*IdempotencyFailure)(nil), // 130: common.IdempotencyFailure - (*TransactionReferenceValue)(nil), // 131: common.TransactionReferenceValue - (*NumscriptVersionValue)(nil), // 132: common.NumscriptVersionValue - (*SegmentType)(nil), // 133: common.SegmentType - (*UUIDConstraint)(nil), // 134: common.UUIDConstraint - (*Uint64Constraint)(nil), // 135: common.Uint64Constraint - (*BytesConstraint)(nil), // 136: common.BytesConstraint - (*AccountType)(nil), // 137: common.AccountType - (*AddedAccountTypeLog)(nil), // 138: common.AddedAccountTypeLog - (*RemovedAccountTypeLog)(nil), // 139: common.RemovedAccountTypeLog - (*UpdatedDefaultEnforcementModeLog)(nil), // 140: common.UpdatedDefaultEnforcementModeLog - (*QueryFilter)(nil), // 141: common.QueryFilter - (*ReferenceCondition)(nil), // 142: common.ReferenceCondition - (*RevertedCondition)(nil), // 143: common.RevertedCondition - (*AuditCondition)(nil), // 144: common.AuditCondition - (*LedgerCondition)(nil), // 145: common.LedgerCondition - (*LogIdCondition)(nil), // 146: common.LogIdCondition - (*BuiltinUintCondition)(nil), // 147: common.BuiltinUintCondition - (*LogBuiltinUintCondition)(nil), // 148: common.LogBuiltinUintCondition - (*AccountHasAssetCondition)(nil), // 149: common.AccountHasAssetCondition - (*AndFilter)(nil), // 150: common.AndFilter - (*OrFilter)(nil), // 151: common.OrFilter - (*NotFilter)(nil), // 152: common.NotFilter - (*FieldRef)(nil), // 153: common.FieldRef - (*FieldCondition)(nil), // 154: common.FieldCondition - (*StringCondition)(nil), // 155: common.StringCondition - (*IntCondition)(nil), // 156: common.IntCondition - (*UintCondition)(nil), // 157: common.UintCondition - (*BoolCondition)(nil), // 158: common.BoolCondition - (*ExistsCondition)(nil), // 159: common.ExistsCondition - (*AddressMatch)(nil), // 160: common.AddressMatch - (*PreparedQuery)(nil), // 161: common.PreparedQuery - (*AggregatedVolume)(nil), // 162: common.AggregatedVolume - (*AggregateResult)(nil), // 163: common.AggregateResult - (*GroupedAggregateResult)(nil), // 164: common.GroupedAggregateResult - (*PreparedQueryCursor)(nil), // 165: common.PreparedQueryCursor - (*LedgerStats)(nil), // 166: common.LedgerStats - (*PersistedConfig)(nil), // 167: common.PersistedConfig - (*CallerIdentity)(nil), // 168: common.CallerIdentity - (*CallerSnapshot)(nil), // 169: common.CallerSnapshot - (*S3StorageConfig)(nil), // 170: common.S3StorageConfig - (*AzureStorageConfig)(nil), // 171: common.AzureStorageConfig - (*BackupStorage)(nil), // 172: common.BackupStorage - (*ReadOptions)(nil), // 173: common.ReadOptions - (*ListOptions)(nil), // 174: common.ListOptions - nil, // 175: common.MetadataMap.ValuesEntry - nil, // 176: common.Transaction.MetadataEntry - nil, // 177: common.Script.VarsEntry - nil, // 178: common.VolumesByAssets.VolumesEntry - nil, // 179: common.PostCommitVolumes.VolumesByAccountEntry - nil, // 180: common.Account.MetadataEntry - nil, // 181: common.Account.VolumesEntry + (*VolumeEntry)(nil), // 30: common.VolumeEntry + (*PostCommitVolumes)(nil), // 31: common.PostCommitVolumes + (*AccountVolume)(nil), // 32: common.AccountVolume + (*Account)(nil), // 33: common.Account + (*TargetAccount)(nil), // 34: common.TargetAccount + (*Target)(nil), // 35: common.Target + (*MetadataFieldSchema)(nil), // 36: common.MetadataFieldSchema + (*MetadataSchema)(nil), // 37: common.MetadataSchema + (*SetMetadataFieldTypeCommand)(nil), // 38: common.SetMetadataFieldTypeCommand + (*MetadataIndexID)(nil), // 39: common.MetadataIndexID + (*IndexID)(nil), // 40: common.IndexID + (*Index)(nil), // 41: common.Index + (*Idempotency)(nil), // 42: common.Idempotency + (*IdempotencyEntry)(nil), // 43: common.IdempotencyEntry + (*Log)(nil), // 44: common.Log + (*LogPayload)(nil), // 45: common.LogPayload + (*PromotedLedgerLog)(nil), // 46: common.PromotedLedgerLog + (*RegisteredSigningKeyLog)(nil), // 47: common.RegisteredSigningKeyLog + (*RevokedSigningKeyLog)(nil), // 48: common.RevokedSigningKeyLog + (*SigningKey)(nil), // 49: common.SigningKey + (*SetSigningConfigLog)(nil), // 50: common.SetSigningConfigLog + (*AddedEventsSinkLog)(nil), // 51: common.AddedEventsSinkLog + (*RemovedEventsSinkLog)(nil), // 52: common.RemovedEventsSinkLog + (*SetMaintenanceModeLog)(nil), // 53: common.SetMaintenanceModeLog + (*BloomTypeConfig)(nil), // 54: common.BloomTypeConfig + (*ClusterConfig)(nil), // 55: common.ClusterConfig + (*PersistedClusterState)(nil), // 56: common.PersistedClusterState + (*SetChapterScheduleLog)(nil), // 57: common.SetChapterScheduleLog + (*DeletedChapterScheduleLog)(nil), // 58: common.DeletedChapterScheduleLog + (*CreatedPreparedQueryLog)(nil), // 59: common.CreatedPreparedQueryLog + (*UpdatedPreparedQueryLog)(nil), // 60: common.UpdatedPreparedQueryLog + (*DeletedPreparedQueryLog)(nil), // 61: common.DeletedPreparedQueryLog + (*SavedLedgerMetadataLog)(nil), // 62: common.SavedLedgerMetadataLog + (*DeletedLedgerMetadataLog)(nil), // 63: common.DeletedLedgerMetadataLog + (*NumscriptInfo)(nil), // 64: common.NumscriptInfo + (*SavedNumscriptLog)(nil), // 65: common.SavedNumscriptLog + (*DeletedNumscriptLog)(nil), // 66: common.DeletedNumscriptLog + (*SetQueryCheckpointScheduleLog)(nil), // 67: common.SetQueryCheckpointScheduleLog + (*DeletedQueryCheckpointScheduleLog)(nil), // 68: common.DeletedQueryCheckpointScheduleLog + (*CreatedQueryCheckpointLog)(nil), // 69: common.CreatedQueryCheckpointLog + (*DeletedQueryCheckpointLog)(nil), // 70: common.DeletedQueryCheckpointLog + (*SinkConfig)(nil), // 71: common.SinkConfig + (*SinkStatus)(nil), // 72: common.SinkStatus + (*SinkError)(nil), // 73: common.SinkError + (*NatsSinkConfig)(nil), // 74: common.NatsSinkConfig + (*ClickHouseSinkConfig)(nil), // 75: common.ClickHouseSinkConfig + (*KafkaSinkConfig)(nil), // 76: common.KafkaSinkConfig + (*HttpSinkConfig)(nil), // 77: common.HttpSinkConfig + (*DatabricksSinkConfig)(nil), // 78: common.DatabricksSinkConfig + (*DatabricksOAuthM2M)(nil), // 79: common.DatabricksOAuthM2M + (*CreatedLedgerLog)(nil), // 80: common.CreatedLedgerLog + (*DeletedLedgerLog)(nil), // 81: common.DeletedLedgerLog + (*ApplyLedgerLog)(nil), // 82: common.ApplyLedgerLog + (*LedgerLog)(nil), // 83: common.LedgerLog + (*TouchedVolume)(nil), // 84: common.TouchedVolume + (*LedgerLogPayload)(nil), // 85: common.LedgerLogPayload + (*OrderSkippedLog)(nil), // 86: common.OrderSkippedLog + (*CreatedIndexLog)(nil), // 87: common.CreatedIndexLog + (*DroppedIndexLog)(nil), // 88: common.DroppedIndexLog + (*FilledGapLog)(nil), // 89: common.FilledGapLog + (*CreatedTransaction)(nil), // 90: common.CreatedTransaction + (*RevertedTransaction)(nil), // 91: common.RevertedTransaction + (*SavedMetadata)(nil), // 92: common.SavedMetadata + (*DeletedMetadata)(nil), // 93: common.DeletedMetadata + (*SetMetadataFieldTypeLog)(nil), // 94: common.SetMetadataFieldTypeLog + (*RemovedMetadataFieldTypeLog)(nil), // 95: common.RemovedMetadataFieldTypeLog + (*Chapter)(nil), // 96: common.Chapter + (*ClosedChapterLog)(nil), // 97: common.ClosedChapterLog + (*SealedChapterLog)(nil), // 98: common.SealedChapterLog + (*ArchivedChapterLog)(nil), // 99: common.ArchivedChapterLog + (*ConfirmedArchiveChapterLog)(nil), // 100: common.ConfirmedArchiveChapterLog + (*MirrorSourceConfig)(nil), // 101: common.MirrorSourceConfig + (*MirrorRewriteRule)(nil), // 102: common.MirrorRewriteRule + (*CreatedTransactionRule)(nil), // 103: common.CreatedTransactionRule + (*RevertedTransactionRule)(nil), // 104: common.RevertedTransactionRule + (*SavedMetadataRule)(nil), // 105: common.SavedMetadataRule + (*DeletedMetadataRule)(nil), // 106: common.DeletedMetadataRule + (*AnyVariantRule)(nil), // 107: common.AnyVariantRule + (*CreatedTransactionAction)(nil), // 108: common.CreatedTransactionAction + (*RevertedTransactionAction)(nil), // 109: common.RevertedTransactionAction + (*SavedMetadataAction)(nil), // 110: common.SavedMetadataAction + (*DeletedMetadataAction)(nil), // 111: common.DeletedMetadataAction + (*AnyVariantAction)(nil), // 112: common.AnyVariantAction + (*RewriteAddressAction)(nil), // 113: common.RewriteAddressAction + (*SetMetadataAction)(nil), // 114: common.SetMetadataAction + (*DeleteMetadataAction)(nil), // 115: common.DeleteMetadataAction + (*SetAccountMetadataAction)(nil), // 116: common.SetAccountMetadataAction + (*DeleteAccountMetadataAction)(nil), // 117: common.DeleteAccountMetadataAction + (*SetAccountMetadataFromAddressAction)(nil), // 118: common.SetAccountMetadataFromAddressAction + (*SetAccountMetadataFromAddressReplacement)(nil), // 119: common.SetAccountMetadataFromAddressReplacement + (*DropAction)(nil), // 120: common.DropAction + (*HttpMirrorSourceConfig)(nil), // 121: common.HttpMirrorSourceConfig + (*OAuth2ClientCredentials)(nil), // 122: common.OAuth2ClientCredentials + (*PostgresMirrorSourceConfig)(nil), // 123: common.PostgresMirrorSourceConfig + (*PostgresAwsIamAuth)(nil), // 124: common.PostgresAwsIamAuth + (*MirrorSyncError)(nil), // 125: common.MirrorSyncError + (*MirrorSyncProgress)(nil), // 126: common.MirrorSyncProgress + (*LedgerInfo)(nil), // 127: common.LedgerInfo + (*SaveMetadataCommand)(nil), // 128: common.SaveMetadataCommand + (*DeleteMetadataCommand)(nil), // 129: common.DeleteMetadataCommand + (*TransactionState)(nil), // 130: common.TransactionState + (*IdempotencyKeyValue)(nil), // 131: common.IdempotencyKeyValue + (*IdempotencyFailure)(nil), // 132: common.IdempotencyFailure + (*TransactionReferenceValue)(nil), // 133: common.TransactionReferenceValue + (*NumscriptVersionValue)(nil), // 134: common.NumscriptVersionValue + (*SegmentType)(nil), // 135: common.SegmentType + (*UUIDConstraint)(nil), // 136: common.UUIDConstraint + (*Uint64Constraint)(nil), // 137: common.Uint64Constraint + (*BytesConstraint)(nil), // 138: common.BytesConstraint + (*AccountType)(nil), // 139: common.AccountType + (*AddedAccountTypeLog)(nil), // 140: common.AddedAccountTypeLog + (*RemovedAccountTypeLog)(nil), // 141: common.RemovedAccountTypeLog + (*UpdatedDefaultEnforcementModeLog)(nil), // 142: common.UpdatedDefaultEnforcementModeLog + (*QueryFilter)(nil), // 143: common.QueryFilter + (*ReferenceCondition)(nil), // 144: common.ReferenceCondition + (*RevertedCondition)(nil), // 145: common.RevertedCondition + (*AuditCondition)(nil), // 146: common.AuditCondition + (*LedgerCondition)(nil), // 147: common.LedgerCondition + (*LogIdCondition)(nil), // 148: common.LogIdCondition + (*BuiltinUintCondition)(nil), // 149: common.BuiltinUintCondition + (*LogBuiltinUintCondition)(nil), // 150: common.LogBuiltinUintCondition + (*AccountHasAssetCondition)(nil), // 151: common.AccountHasAssetCondition + (*AndFilter)(nil), // 152: common.AndFilter + (*OrFilter)(nil), // 153: common.OrFilter + (*NotFilter)(nil), // 154: common.NotFilter + (*FieldRef)(nil), // 155: common.FieldRef + (*FieldCondition)(nil), // 156: common.FieldCondition + (*StringCondition)(nil), // 157: common.StringCondition + (*IntCondition)(nil), // 158: common.IntCondition + (*UintCondition)(nil), // 159: common.UintCondition + (*BoolCondition)(nil), // 160: common.BoolCondition + (*ExistsCondition)(nil), // 161: common.ExistsCondition + (*AddressMatch)(nil), // 162: common.AddressMatch + (*PreparedQuery)(nil), // 163: common.PreparedQuery + (*AggregatedVolume)(nil), // 164: common.AggregatedVolume + (*AggregateResult)(nil), // 165: common.AggregateResult + (*GroupedAggregateResult)(nil), // 166: common.GroupedAggregateResult + (*PreparedQueryCursor)(nil), // 167: common.PreparedQueryCursor + (*LedgerStats)(nil), // 168: common.LedgerStats + (*PersistedConfig)(nil), // 169: common.PersistedConfig + (*CallerIdentity)(nil), // 170: common.CallerIdentity + (*CallerSnapshot)(nil), // 171: common.CallerSnapshot + (*S3StorageConfig)(nil), // 172: common.S3StorageConfig + (*AzureStorageConfig)(nil), // 173: common.AzureStorageConfig + (*BackupStorage)(nil), // 174: common.BackupStorage + (*ReadOptions)(nil), // 175: common.ReadOptions + (*ListOptions)(nil), // 176: common.ListOptions + nil, // 177: common.MetadataMap.ValuesEntry + nil, // 178: common.Transaction.MetadataEntry + nil, // 179: common.Script.VarsEntry + nil, // 180: common.PostCommitVolumes.VolumesByAccountEntry + nil, // 181: common.Account.MetadataEntry nil, // 182: common.MetadataSchema.AccountFieldsEntry nil, // 183: common.MetadataSchema.TransactionFieldsEntry nil, // 184: common.MetadataSchema.LedgerFieldsEntry @@ -13831,275 +14006,275 @@ var file_common_proto_goTypes = []any{ } var file_common_proto_depIdxs = []int32{ 19, // 0: common.MetadataValue.null_value:type_name -> common.NullValue - 175, // 1: common.MetadataMap.values:type_name -> common.MetadataMap.ValuesEntry + 177, // 1: common.MetadataMap.values:type_name -> common.MetadataMap.ValuesEntry 23, // 2: common.Posting.amount:type_name -> common.Uint256 24, // 3: common.Transaction.postings:type_name -> common.Posting - 176, // 4: common.Transaction.metadata:type_name -> common.Transaction.MetadataEntry + 178, // 4: common.Transaction.metadata:type_name -> common.Transaction.MetadataEntry 18, // 5: common.Transaction.timestamp:type_name -> common.Timestamp 18, // 6: common.Transaction.inserted_at:type_name -> common.Timestamp 18, // 7: common.Transaction.updated_at:type_name -> common.Timestamp 18, // 8: common.Transaction.reverted_at:type_name -> common.Timestamp - 177, // 9: common.Script.vars:type_name -> common.Script.VarsEntry - 178, // 10: common.VolumesByAssets.volumes:type_name -> common.VolumesByAssets.VolumesEntry - 179, // 11: common.PostCommitVolumes.volumes_by_account:type_name -> common.PostCommitVolumes.VolumesByAccountEntry - 180, // 12: common.Account.metadata:type_name -> common.Account.MetadataEntry - 18, // 13: common.Account.first_usage:type_name -> common.Timestamp - 18, // 14: common.Account.insertion_date:type_name -> common.Timestamp - 18, // 15: common.Account.updated_at:type_name -> common.Timestamp - 181, // 16: common.Account.volumes:type_name -> common.Account.VolumesEntry - 32, // 17: common.Target.account:type_name -> common.TargetAccount - 1, // 18: common.MetadataFieldSchema.type:type_name -> common.MetadataType - 182, // 19: common.MetadataSchema.account_fields:type_name -> common.MetadataSchema.AccountFieldsEntry - 183, // 20: common.MetadataSchema.transaction_fields:type_name -> common.MetadataSchema.TransactionFieldsEntry - 184, // 21: common.MetadataSchema.ledger_fields:type_name -> common.MetadataSchema.LedgerFieldsEntry - 0, // 22: common.SetMetadataFieldTypeCommand.target_type:type_name -> common.TargetType - 1, // 23: common.SetMetadataFieldTypeCommand.type:type_name -> common.MetadataType - 0, // 24: common.MetadataIndexID.target:type_name -> common.TargetType - 3, // 25: common.IndexID.tx_builtin:type_name -> common.TransactionBuiltinIndex - 5, // 26: common.IndexID.log_builtin:type_name -> common.LogBuiltinIndex - 4, // 27: common.IndexID.account_builtin:type_name -> common.AccountBuiltinIndex - 37, // 28: common.IndexID.metadata:type_name -> common.MetadataIndexID - 38, // 29: common.Index.id:type_name -> common.IndexID - 2, // 30: common.Index.build_status:type_name -> common.IndexBuildStatus - 18, // 31: common.Index.created_at:type_name -> common.Timestamp - 18, // 32: common.Index.last_built_at:type_name -> common.Timestamp - 43, // 33: common.Log.payload:type_name -> common.LogPayload - 196, // 34: common.Log.response_signature:type_name -> signature.SignedLog - 78, // 35: common.LogPayload.create_ledger:type_name -> common.CreatedLedgerLog - 79, // 36: common.LogPayload.delete_ledger:type_name -> common.DeletedLedgerLog - 80, // 37: common.LogPayload.apply:type_name -> common.ApplyLedgerLog - 45, // 38: common.LogPayload.register_signing_key:type_name -> common.RegisteredSigningKeyLog - 46, // 39: common.LogPayload.revoke_signing_key:type_name -> common.RevokedSigningKeyLog - 48, // 40: common.LogPayload.set_signing_config:type_name -> common.SetSigningConfigLog - 49, // 41: common.LogPayload.added_events_sink:type_name -> common.AddedEventsSinkLog - 50, // 42: common.LogPayload.removed_events_sink:type_name -> common.RemovedEventsSinkLog - 95, // 43: common.LogPayload.close_chapter:type_name -> common.ClosedChapterLog - 96, // 44: common.LogPayload.seal_chapter:type_name -> common.SealedChapterLog - 97, // 45: common.LogPayload.archive_chapter:type_name -> common.ArchivedChapterLog - 98, // 46: common.LogPayload.confirm_archive_chapter:type_name -> common.ConfirmedArchiveChapterLog - 51, // 47: common.LogPayload.set_maintenance_mode:type_name -> common.SetMaintenanceModeLog - 55, // 48: common.LogPayload.set_chapter_schedule:type_name -> common.SetChapterScheduleLog - 56, // 49: common.LogPayload.delete_chapter_schedule:type_name -> common.DeletedChapterScheduleLog - 44, // 50: common.LogPayload.promote_ledger:type_name -> common.PromotedLedgerLog - 57, // 51: common.LogPayload.created_prepared_query:type_name -> common.CreatedPreparedQueryLog - 58, // 52: common.LogPayload.updated_prepared_query:type_name -> common.UpdatedPreparedQueryLog - 59, // 53: common.LogPayload.deleted_prepared_query:type_name -> common.DeletedPreparedQueryLog - 63, // 54: common.LogPayload.saved_numscript:type_name -> common.SavedNumscriptLog - 64, // 55: common.LogPayload.deleted_numscript:type_name -> common.DeletedNumscriptLog - 67, // 56: common.LogPayload.created_query_checkpoint:type_name -> common.CreatedQueryCheckpointLog - 68, // 57: common.LogPayload.deleted_query_checkpoint:type_name -> common.DeletedQueryCheckpointLog - 65, // 58: common.LogPayload.set_query_checkpoint_schedule:type_name -> common.SetQueryCheckpointScheduleLog - 66, // 59: common.LogPayload.delete_query_checkpoint_schedule:type_name -> common.DeletedQueryCheckpointScheduleLog - 60, // 60: common.LogPayload.saved_ledger_metadata:type_name -> common.SavedLedgerMetadataLog - 61, // 61: common.LogPayload.deleted_ledger_metadata:type_name -> common.DeletedLedgerMetadataLog - 69, // 62: common.AddedEventsSinkLog.config:type_name -> common.SinkConfig - 52, // 63: common.ClusterConfig.bloom_volumes:type_name -> common.BloomTypeConfig - 52, // 64: common.ClusterConfig.bloom_metadata:type_name -> common.BloomTypeConfig - 52, // 65: common.ClusterConfig.bloom_references:type_name -> common.BloomTypeConfig - 52, // 66: common.ClusterConfig.bloom_ledgers:type_name -> common.BloomTypeConfig - 52, // 67: common.ClusterConfig.bloom_boundaries:type_name -> common.BloomTypeConfig - 52, // 68: common.ClusterConfig.bloom_transactions:type_name -> common.BloomTypeConfig - 52, // 69: common.ClusterConfig.bloom_sink_configs:type_name -> common.BloomTypeConfig - 52, // 70: common.ClusterConfig.bloom_numscript_versions:type_name -> common.BloomTypeConfig - 52, // 71: common.ClusterConfig.bloom_numscript_contents:type_name -> common.BloomTypeConfig - 6, // 72: common.ClusterConfig.hash_algorithm:type_name -> common.HashAlgorithm - 52, // 73: common.ClusterConfig.bloom_ledger_metadata:type_name -> common.BloomTypeConfig - 52, // 74: common.ClusterConfig.bloom_prepared_queries:type_name -> common.BloomTypeConfig - 52, // 75: common.ClusterConfig.bloom_indexes:type_name -> common.BloomTypeConfig - 53, // 76: common.PersistedClusterState.config:type_name -> common.ClusterConfig - 161, // 77: common.CreatedPreparedQueryLog.query:type_name -> common.PreparedQuery - 141, // 78: common.UpdatedPreparedQueryLog.previous_filter:type_name -> common.QueryFilter - 141, // 79: common.UpdatedPreparedQueryLog.new_filter:type_name -> common.QueryFilter - 185, // 80: common.SavedLedgerMetadataLog.metadata:type_name -> common.SavedLedgerMetadataLog.MetadataEntry - 18, // 81: common.NumscriptInfo.created_at:type_name -> common.Timestamp - 62, // 82: common.SavedNumscriptLog.info:type_name -> common.NumscriptInfo - 72, // 83: common.SinkConfig.nats:type_name -> common.NatsSinkConfig - 73, // 84: common.SinkConfig.clickhouse:type_name -> common.ClickHouseSinkConfig - 74, // 85: common.SinkConfig.kafka:type_name -> common.KafkaSinkConfig - 75, // 86: common.SinkConfig.http:type_name -> common.HttpSinkConfig - 76, // 87: common.SinkConfig.databricks:type_name -> common.DatabricksSinkConfig - 7, // 88: common.SinkConfig.event_types:type_name -> common.EventType - 71, // 89: common.SinkStatus.error:type_name -> common.SinkError - 18, // 90: common.SinkError.occurred_at:type_name -> common.Timestamp - 77, // 91: common.DatabricksSinkConfig.oauth_m2m:type_name -> common.DatabricksOAuthM2M - 18, // 92: common.CreatedLedgerLog.created_at:type_name -> common.Timestamp - 35, // 93: common.CreatedLedgerLog.metadata_schema:type_name -> common.MetadataSchema - 9, // 94: common.CreatedLedgerLog.mode:type_name -> common.LedgerMode - 99, // 95: common.CreatedLedgerLog.mirror_source:type_name -> common.MirrorSourceConfig - 186, // 96: common.CreatedLedgerLog.account_types:type_name -> common.CreatedLedgerLog.AccountTypesEntry - 12, // 97: common.CreatedLedgerLog.default_enforcement_mode:type_name -> common.ChartEnforcementMode - 18, // 98: common.DeletedLedgerLog.deleted_at:type_name -> common.Timestamp - 81, // 99: common.ApplyLedgerLog.log:type_name -> common.LedgerLog - 83, // 100: common.LedgerLog.data:type_name -> common.LedgerLogPayload - 18, // 101: common.LedgerLog.date:type_name -> common.Timestamp - 82, // 102: common.LedgerLog.purged_volumes:type_name -> common.TouchedVolume - 88, // 103: common.LedgerLogPayload.created_transaction:type_name -> common.CreatedTransaction - 89, // 104: common.LedgerLogPayload.reverted_transaction:type_name -> common.RevertedTransaction - 90, // 105: common.LedgerLogPayload.saved_metadata:type_name -> common.SavedMetadata - 91, // 106: common.LedgerLogPayload.deleted_metadata:type_name -> common.DeletedMetadata - 92, // 107: common.LedgerLogPayload.set_metadata_field_type:type_name -> common.SetMetadataFieldTypeLog - 93, // 108: common.LedgerLogPayload.removed_metadata_field_type:type_name -> common.RemovedMetadataFieldTypeLog - 87, // 109: common.LedgerLogPayload.fill_gap:type_name -> common.FilledGapLog - 85, // 110: common.LedgerLogPayload.create_index:type_name -> common.CreatedIndexLog - 86, // 111: common.LedgerLogPayload.drop_index:type_name -> common.DroppedIndexLog - 138, // 112: common.LedgerLogPayload.added_account_type:type_name -> common.AddedAccountTypeLog - 139, // 113: common.LedgerLogPayload.removed_account_type:type_name -> common.RemovedAccountTypeLog - 140, // 114: common.LedgerLogPayload.updated_default_enforcement_mode:type_name -> common.UpdatedDefaultEnforcementModeLog - 84, // 115: common.LedgerLogPayload.order_skipped:type_name -> common.OrderSkippedLog - 11, // 116: common.OrderSkippedLog.reason:type_name -> common.ErrorReason - 187, // 117: common.OrderSkippedLog.context:type_name -> common.OrderSkippedLog.ContextEntry - 38, // 118: common.CreatedIndexLog.id:type_name -> common.IndexID - 38, // 119: common.DroppedIndexLog.id:type_name -> common.IndexID - 25, // 120: common.CreatedTransaction.transaction:type_name -> common.Transaction - 188, // 121: common.CreatedTransaction.account_metadata:type_name -> common.CreatedTransaction.AccountMetadataEntry - 30, // 122: common.CreatedTransaction.post_commit_volumes:type_name -> common.PostCommitVolumes - 25, // 123: common.RevertedTransaction.revert_transaction:type_name -> common.Transaction - 30, // 124: common.RevertedTransaction.post_commit_volumes:type_name -> common.PostCommitVolumes - 33, // 125: common.SavedMetadata.target:type_name -> common.Target - 189, // 126: common.SavedMetadata.metadata:type_name -> common.SavedMetadata.MetadataEntry - 33, // 127: common.DeletedMetadata.target:type_name -> common.Target - 0, // 128: common.SetMetadataFieldTypeLog.target_type:type_name -> common.TargetType - 1, // 129: common.SetMetadataFieldTypeLog.type:type_name -> common.MetadataType - 0, // 130: common.RemovedMetadataFieldTypeLog.target_type:type_name -> common.TargetType - 38, // 131: common.RemovedMetadataFieldTypeLog.dropped_index:type_name -> common.IndexID - 18, // 132: common.Chapter.start:type_name -> common.Timestamp - 18, // 133: common.Chapter.end:type_name -> common.Timestamp - 8, // 134: common.Chapter.status:type_name -> common.ChapterStatus - 94, // 135: common.ClosedChapterLog.closed_chapter:type_name -> common.Chapter - 94, // 136: common.ClosedChapterLog.new_chapter:type_name -> common.Chapter - 94, // 137: common.SealedChapterLog.chapter:type_name -> common.Chapter - 94, // 138: common.ArchivedChapterLog.chapter:type_name -> common.Chapter - 94, // 139: common.ConfirmedArchiveChapterLog.chapter:type_name -> common.Chapter - 119, // 140: common.MirrorSourceConfig.http:type_name -> common.HttpMirrorSourceConfig - 121, // 141: common.MirrorSourceConfig.postgres:type_name -> common.PostgresMirrorSourceConfig - 100, // 142: common.MirrorSourceConfig.rewrite_rules:type_name -> common.MirrorRewriteRule - 101, // 143: common.MirrorRewriteRule.created_transaction:type_name -> common.CreatedTransactionRule - 102, // 144: common.MirrorRewriteRule.reverted_transaction:type_name -> common.RevertedTransactionRule - 103, // 145: common.MirrorRewriteRule.saved_metadata:type_name -> common.SavedMetadataRule - 104, // 146: common.MirrorRewriteRule.deleted_metadata:type_name -> common.DeletedMetadataRule - 105, // 147: common.MirrorRewriteRule.any_variant:type_name -> common.AnyVariantRule - 106, // 148: common.CreatedTransactionRule.actions:type_name -> common.CreatedTransactionAction - 107, // 149: common.RevertedTransactionRule.actions:type_name -> common.RevertedTransactionAction - 108, // 150: common.SavedMetadataRule.actions:type_name -> common.SavedMetadataAction - 109, // 151: common.DeletedMetadataRule.actions:type_name -> common.DeletedMetadataAction - 110, // 152: common.AnyVariantRule.actions:type_name -> common.AnyVariantAction - 111, // 153: common.CreatedTransactionAction.rewrite_address:type_name -> common.RewriteAddressAction - 112, // 154: common.CreatedTransactionAction.set_metadata:type_name -> common.SetMetadataAction - 113, // 155: common.CreatedTransactionAction.delete_metadata:type_name -> common.DeleteMetadataAction - 114, // 156: common.CreatedTransactionAction.set_account_metadata:type_name -> common.SetAccountMetadataAction - 115, // 157: common.CreatedTransactionAction.delete_account_metadata:type_name -> common.DeleteAccountMetadataAction - 116, // 158: common.CreatedTransactionAction.set_account_metadata_from_address:type_name -> common.SetAccountMetadataFromAddressAction - 118, // 159: common.CreatedTransactionAction.drop:type_name -> common.DropAction - 111, // 160: common.RevertedTransactionAction.rewrite_address:type_name -> common.RewriteAddressAction - 112, // 161: common.RevertedTransactionAction.set_metadata:type_name -> common.SetMetadataAction - 113, // 162: common.RevertedTransactionAction.delete_metadata:type_name -> common.DeleteMetadataAction - 118, // 163: common.RevertedTransactionAction.drop:type_name -> common.DropAction - 111, // 164: common.SavedMetadataAction.rewrite_address:type_name -> common.RewriteAddressAction - 112, // 165: common.SavedMetadataAction.set_metadata:type_name -> common.SetMetadataAction - 113, // 166: common.SavedMetadataAction.delete_metadata:type_name -> common.DeleteMetadataAction - 118, // 167: common.SavedMetadataAction.drop:type_name -> common.DropAction - 111, // 168: common.DeletedMetadataAction.rewrite_address:type_name -> common.RewriteAddressAction - 118, // 169: common.DeletedMetadataAction.drop:type_name -> common.DropAction - 111, // 170: common.AnyVariantAction.rewrite_address:type_name -> common.RewriteAddressAction - 118, // 171: common.AnyVariantAction.drop:type_name -> common.DropAction - 117, // 172: common.SetAccountMetadataFromAddressAction.replacements:type_name -> common.SetAccountMetadataFromAddressReplacement - 120, // 173: common.HttpMirrorSourceConfig.oauth2_client_credentials:type_name -> common.OAuth2ClientCredentials - 122, // 174: common.PostgresMirrorSourceConfig.aws_iam_auth:type_name -> common.PostgresAwsIamAuth - 18, // 175: common.MirrorSyncError.occurred_at:type_name -> common.Timestamp - 10, // 176: common.MirrorSyncProgress.state:type_name -> common.MirrorSyncState - 123, // 177: common.MirrorSyncProgress.error:type_name -> common.MirrorSyncError - 18, // 178: common.LedgerInfo.created_at:type_name -> common.Timestamp - 18, // 179: common.LedgerInfo.deleted_at:type_name -> common.Timestamp - 35, // 180: common.LedgerInfo.metadata_schema:type_name -> common.MetadataSchema - 9, // 181: common.LedgerInfo.mode:type_name -> common.LedgerMode - 99, // 182: common.LedgerInfo.mirror_source:type_name -> common.MirrorSourceConfig - 124, // 183: common.LedgerInfo.mirror_sync_progress:type_name -> common.MirrorSyncProgress - 190, // 184: common.LedgerInfo.account_types:type_name -> common.LedgerInfo.AccountTypesEntry - 12, // 185: common.LedgerInfo.default_enforcement_mode:type_name -> common.ChartEnforcementMode - 191, // 186: common.LedgerInfo.metadata:type_name -> common.LedgerInfo.MetadataEntry - 33, // 187: common.SaveMetadataCommand.target:type_name -> common.Target - 192, // 188: common.SaveMetadataCommand.metadata:type_name -> common.SaveMetadataCommand.MetadataEntry - 33, // 189: common.DeleteMetadataCommand.target:type_name -> common.Target - 193, // 190: common.TransactionState.metadata:type_name -> common.TransactionState.MetadataEntry - 18, // 191: common.TransactionState.timestamp:type_name -> common.Timestamp - 24, // 192: common.TransactionState.postings:type_name -> common.Posting - 18, // 193: common.TransactionState.reverted_at:type_name -> common.Timestamp - 130, // 194: common.IdempotencyKeyValue.failure:type_name -> common.IdempotencyFailure - 11, // 195: common.IdempotencyFailure.reason:type_name -> common.ErrorReason - 194, // 196: common.IdempotencyFailure.metadata:type_name -> common.IdempotencyFailure.MetadataEntry - 134, // 197: common.SegmentType.uuid:type_name -> common.UUIDConstraint - 135, // 198: common.SegmentType.uint64:type_name -> common.Uint64Constraint - 136, // 199: common.SegmentType.bytes:type_name -> common.BytesConstraint - 13, // 200: common.AccountType.persistence:type_name -> common.AccountTypePersistence - 195, // 201: common.AccountType.segment_types:type_name -> common.AccountType.SegmentTypesEntry - 137, // 202: common.AddedAccountTypeLog.account_type:type_name -> common.AccountType - 12, // 203: common.UpdatedDefaultEnforcementModeLog.enforcement_mode:type_name -> common.ChartEnforcementMode - 154, // 204: common.QueryFilter.field:type_name -> common.FieldCondition - 160, // 205: common.QueryFilter.address:type_name -> common.AddressMatch - 150, // 206: common.QueryFilter.and:type_name -> common.AndFilter - 151, // 207: common.QueryFilter.or:type_name -> common.OrFilter - 152, // 208: common.QueryFilter.not:type_name -> common.NotFilter - 142, // 209: common.QueryFilter.reference:type_name -> common.ReferenceCondition - 147, // 210: common.QueryFilter.builtin_uint:type_name -> common.BuiltinUintCondition - 145, // 211: common.QueryFilter.ledger:type_name -> common.LedgerCondition - 146, // 212: common.QueryFilter.log_id:type_name -> common.LogIdCondition - 148, // 213: common.QueryFilter.log_builtin_uint:type_name -> common.LogBuiltinUintCondition - 149, // 214: common.QueryFilter.account_has_asset:type_name -> common.AccountHasAssetCondition - 143, // 215: common.QueryFilter.reverted:type_name -> common.RevertedCondition - 144, // 216: common.QueryFilter.audit:type_name -> common.AuditCondition - 155, // 217: common.ReferenceCondition.cond:type_name -> common.StringCondition - 14, // 218: common.AuditCondition.field:type_name -> common.AuditField - 155, // 219: common.AuditCondition.string_cond:type_name -> common.StringCondition - 157, // 220: common.AuditCondition.uint_cond:type_name -> common.UintCondition - 155, // 221: common.LedgerCondition.cond:type_name -> common.StringCondition - 157, // 222: common.LogIdCondition.cond:type_name -> common.UintCondition - 3, // 223: common.BuiltinUintCondition.field:type_name -> common.TransactionBuiltinIndex - 157, // 224: common.BuiltinUintCondition.cond:type_name -> common.UintCondition - 5, // 225: common.LogBuiltinUintCondition.field:type_name -> common.LogBuiltinIndex - 157, // 226: common.LogBuiltinUintCondition.cond:type_name -> common.UintCondition - 141, // 227: common.AndFilter.filters:type_name -> common.QueryFilter - 141, // 228: common.OrFilter.filters:type_name -> common.QueryFilter - 141, // 229: common.NotFilter.filter:type_name -> common.QueryFilter - 153, // 230: common.FieldCondition.field:type_name -> common.FieldRef - 155, // 231: common.FieldCondition.string_cond:type_name -> common.StringCondition - 156, // 232: common.FieldCondition.int_cond:type_name -> common.IntCondition - 157, // 233: common.FieldCondition.uint_cond:type_name -> common.UintCondition - 158, // 234: common.FieldCondition.bool_cond:type_name -> common.BoolCondition - 159, // 235: common.FieldCondition.exists_cond:type_name -> common.ExistsCondition - 15, // 236: common.AddressMatch.role:type_name -> common.AddressRole - 141, // 237: common.PreparedQuery.filter:type_name -> common.QueryFilter - 16, // 238: common.PreparedQuery.target:type_name -> common.QueryTarget - 23, // 239: common.AggregatedVolume.input:type_name -> common.Uint256 - 23, // 240: common.AggregatedVolume.output:type_name -> common.Uint256 - 162, // 241: common.AggregateResult.volumes:type_name -> common.AggregatedVolume - 164, // 242: common.AggregateResult.groups:type_name -> common.GroupedAggregateResult - 162, // 243: common.GroupedAggregateResult.volumes:type_name -> common.AggregatedVolume - 31, // 244: common.PreparedQueryCursor.account_data:type_name -> common.Account - 25, // 245: common.PreparedQueryCursor.transaction_data:type_name -> common.Transaction - 42, // 246: common.PreparedQueryCursor.log_data:type_name -> common.Log - 168, // 247: common.CallerSnapshot.identity:type_name -> common.CallerIdentity - 170, // 248: common.BackupStorage.s3:type_name -> common.S3StorageConfig - 171, // 249: common.BackupStorage.azure:type_name -> common.AzureStorageConfig - 173, // 250: common.ListOptions.read:type_name -> common.ReadOptions - 141, // 251: common.ListOptions.filter:type_name -> common.QueryFilter - 20, // 252: common.MetadataMap.ValuesEntry.value:type_name -> common.MetadataValue - 20, // 253: common.Transaction.MetadataEntry.value:type_name -> common.MetadataValue - 27, // 254: common.VolumesByAssets.VolumesEntry.value:type_name -> common.Volumes - 29, // 255: common.PostCommitVolumes.VolumesByAccountEntry.value:type_name -> common.VolumesByAssets - 20, // 256: common.Account.MetadataEntry.value:type_name -> common.MetadataValue - 28, // 257: common.Account.VolumesEntry.value:type_name -> common.VolumesWithBalance - 34, // 258: common.MetadataSchema.AccountFieldsEntry.value:type_name -> common.MetadataFieldSchema - 34, // 259: common.MetadataSchema.TransactionFieldsEntry.value:type_name -> common.MetadataFieldSchema - 34, // 260: common.MetadataSchema.LedgerFieldsEntry.value:type_name -> common.MetadataFieldSchema + 179, // 9: common.Script.vars:type_name -> common.Script.VarsEntry + 30, // 10: common.VolumesByAssets.volumes:type_name -> common.VolumeEntry + 27, // 11: common.VolumeEntry.volumes:type_name -> common.Volumes + 180, // 12: common.PostCommitVolumes.volumes_by_account:type_name -> common.PostCommitVolumes.VolumesByAccountEntry + 28, // 13: common.AccountVolume.volumes:type_name -> common.VolumesWithBalance + 181, // 14: common.Account.metadata:type_name -> common.Account.MetadataEntry + 18, // 15: common.Account.first_usage:type_name -> common.Timestamp + 18, // 16: common.Account.insertion_date:type_name -> common.Timestamp + 18, // 17: common.Account.updated_at:type_name -> common.Timestamp + 32, // 18: common.Account.volumes:type_name -> common.AccountVolume + 34, // 19: common.Target.account:type_name -> common.TargetAccount + 1, // 20: common.MetadataFieldSchema.type:type_name -> common.MetadataType + 182, // 21: common.MetadataSchema.account_fields:type_name -> common.MetadataSchema.AccountFieldsEntry + 183, // 22: common.MetadataSchema.transaction_fields:type_name -> common.MetadataSchema.TransactionFieldsEntry + 184, // 23: common.MetadataSchema.ledger_fields:type_name -> common.MetadataSchema.LedgerFieldsEntry + 0, // 24: common.SetMetadataFieldTypeCommand.target_type:type_name -> common.TargetType + 1, // 25: common.SetMetadataFieldTypeCommand.type:type_name -> common.MetadataType + 0, // 26: common.MetadataIndexID.target:type_name -> common.TargetType + 3, // 27: common.IndexID.tx_builtin:type_name -> common.TransactionBuiltinIndex + 5, // 28: common.IndexID.log_builtin:type_name -> common.LogBuiltinIndex + 4, // 29: common.IndexID.account_builtin:type_name -> common.AccountBuiltinIndex + 39, // 30: common.IndexID.metadata:type_name -> common.MetadataIndexID + 40, // 31: common.Index.id:type_name -> common.IndexID + 2, // 32: common.Index.build_status:type_name -> common.IndexBuildStatus + 18, // 33: common.Index.created_at:type_name -> common.Timestamp + 18, // 34: common.Index.last_built_at:type_name -> common.Timestamp + 45, // 35: common.Log.payload:type_name -> common.LogPayload + 196, // 36: common.Log.response_signature:type_name -> signature.SignedLog + 80, // 37: common.LogPayload.create_ledger:type_name -> common.CreatedLedgerLog + 81, // 38: common.LogPayload.delete_ledger:type_name -> common.DeletedLedgerLog + 82, // 39: common.LogPayload.apply:type_name -> common.ApplyLedgerLog + 47, // 40: common.LogPayload.register_signing_key:type_name -> common.RegisteredSigningKeyLog + 48, // 41: common.LogPayload.revoke_signing_key:type_name -> common.RevokedSigningKeyLog + 50, // 42: common.LogPayload.set_signing_config:type_name -> common.SetSigningConfigLog + 51, // 43: common.LogPayload.added_events_sink:type_name -> common.AddedEventsSinkLog + 52, // 44: common.LogPayload.removed_events_sink:type_name -> common.RemovedEventsSinkLog + 97, // 45: common.LogPayload.close_chapter:type_name -> common.ClosedChapterLog + 98, // 46: common.LogPayload.seal_chapter:type_name -> common.SealedChapterLog + 99, // 47: common.LogPayload.archive_chapter:type_name -> common.ArchivedChapterLog + 100, // 48: common.LogPayload.confirm_archive_chapter:type_name -> common.ConfirmedArchiveChapterLog + 53, // 49: common.LogPayload.set_maintenance_mode:type_name -> common.SetMaintenanceModeLog + 57, // 50: common.LogPayload.set_chapter_schedule:type_name -> common.SetChapterScheduleLog + 58, // 51: common.LogPayload.delete_chapter_schedule:type_name -> common.DeletedChapterScheduleLog + 46, // 52: common.LogPayload.promote_ledger:type_name -> common.PromotedLedgerLog + 59, // 53: common.LogPayload.created_prepared_query:type_name -> common.CreatedPreparedQueryLog + 60, // 54: common.LogPayload.updated_prepared_query:type_name -> common.UpdatedPreparedQueryLog + 61, // 55: common.LogPayload.deleted_prepared_query:type_name -> common.DeletedPreparedQueryLog + 65, // 56: common.LogPayload.saved_numscript:type_name -> common.SavedNumscriptLog + 66, // 57: common.LogPayload.deleted_numscript:type_name -> common.DeletedNumscriptLog + 69, // 58: common.LogPayload.created_query_checkpoint:type_name -> common.CreatedQueryCheckpointLog + 70, // 59: common.LogPayload.deleted_query_checkpoint:type_name -> common.DeletedQueryCheckpointLog + 67, // 60: common.LogPayload.set_query_checkpoint_schedule:type_name -> common.SetQueryCheckpointScheduleLog + 68, // 61: common.LogPayload.delete_query_checkpoint_schedule:type_name -> common.DeletedQueryCheckpointScheduleLog + 62, // 62: common.LogPayload.saved_ledger_metadata:type_name -> common.SavedLedgerMetadataLog + 63, // 63: common.LogPayload.deleted_ledger_metadata:type_name -> common.DeletedLedgerMetadataLog + 71, // 64: common.AddedEventsSinkLog.config:type_name -> common.SinkConfig + 54, // 65: common.ClusterConfig.bloom_volumes:type_name -> common.BloomTypeConfig + 54, // 66: common.ClusterConfig.bloom_metadata:type_name -> common.BloomTypeConfig + 54, // 67: common.ClusterConfig.bloom_references:type_name -> common.BloomTypeConfig + 54, // 68: common.ClusterConfig.bloom_ledgers:type_name -> common.BloomTypeConfig + 54, // 69: common.ClusterConfig.bloom_boundaries:type_name -> common.BloomTypeConfig + 54, // 70: common.ClusterConfig.bloom_transactions:type_name -> common.BloomTypeConfig + 54, // 71: common.ClusterConfig.bloom_sink_configs:type_name -> common.BloomTypeConfig + 54, // 72: common.ClusterConfig.bloom_numscript_versions:type_name -> common.BloomTypeConfig + 54, // 73: common.ClusterConfig.bloom_numscript_contents:type_name -> common.BloomTypeConfig + 6, // 74: common.ClusterConfig.hash_algorithm:type_name -> common.HashAlgorithm + 54, // 75: common.ClusterConfig.bloom_ledger_metadata:type_name -> common.BloomTypeConfig + 54, // 76: common.ClusterConfig.bloom_prepared_queries:type_name -> common.BloomTypeConfig + 54, // 77: common.ClusterConfig.bloom_indexes:type_name -> common.BloomTypeConfig + 55, // 78: common.PersistedClusterState.config:type_name -> common.ClusterConfig + 163, // 79: common.CreatedPreparedQueryLog.query:type_name -> common.PreparedQuery + 143, // 80: common.UpdatedPreparedQueryLog.previous_filter:type_name -> common.QueryFilter + 143, // 81: common.UpdatedPreparedQueryLog.new_filter:type_name -> common.QueryFilter + 185, // 82: common.SavedLedgerMetadataLog.metadata:type_name -> common.SavedLedgerMetadataLog.MetadataEntry + 18, // 83: common.NumscriptInfo.created_at:type_name -> common.Timestamp + 64, // 84: common.SavedNumscriptLog.info:type_name -> common.NumscriptInfo + 74, // 85: common.SinkConfig.nats:type_name -> common.NatsSinkConfig + 75, // 86: common.SinkConfig.clickhouse:type_name -> common.ClickHouseSinkConfig + 76, // 87: common.SinkConfig.kafka:type_name -> common.KafkaSinkConfig + 77, // 88: common.SinkConfig.http:type_name -> common.HttpSinkConfig + 78, // 89: common.SinkConfig.databricks:type_name -> common.DatabricksSinkConfig + 7, // 90: common.SinkConfig.event_types:type_name -> common.EventType + 73, // 91: common.SinkStatus.error:type_name -> common.SinkError + 18, // 92: common.SinkError.occurred_at:type_name -> common.Timestamp + 79, // 93: common.DatabricksSinkConfig.oauth_m2m:type_name -> common.DatabricksOAuthM2M + 18, // 94: common.CreatedLedgerLog.created_at:type_name -> common.Timestamp + 37, // 95: common.CreatedLedgerLog.metadata_schema:type_name -> common.MetadataSchema + 9, // 96: common.CreatedLedgerLog.mode:type_name -> common.LedgerMode + 101, // 97: common.CreatedLedgerLog.mirror_source:type_name -> common.MirrorSourceConfig + 186, // 98: common.CreatedLedgerLog.account_types:type_name -> common.CreatedLedgerLog.AccountTypesEntry + 12, // 99: common.CreatedLedgerLog.default_enforcement_mode:type_name -> common.ChartEnforcementMode + 18, // 100: common.DeletedLedgerLog.deleted_at:type_name -> common.Timestamp + 83, // 101: common.ApplyLedgerLog.log:type_name -> common.LedgerLog + 85, // 102: common.LedgerLog.data:type_name -> common.LedgerLogPayload + 18, // 103: common.LedgerLog.date:type_name -> common.Timestamp + 84, // 104: common.LedgerLog.purged_volumes:type_name -> common.TouchedVolume + 90, // 105: common.LedgerLogPayload.created_transaction:type_name -> common.CreatedTransaction + 91, // 106: common.LedgerLogPayload.reverted_transaction:type_name -> common.RevertedTransaction + 92, // 107: common.LedgerLogPayload.saved_metadata:type_name -> common.SavedMetadata + 93, // 108: common.LedgerLogPayload.deleted_metadata:type_name -> common.DeletedMetadata + 94, // 109: common.LedgerLogPayload.set_metadata_field_type:type_name -> common.SetMetadataFieldTypeLog + 95, // 110: common.LedgerLogPayload.removed_metadata_field_type:type_name -> common.RemovedMetadataFieldTypeLog + 89, // 111: common.LedgerLogPayload.fill_gap:type_name -> common.FilledGapLog + 87, // 112: common.LedgerLogPayload.create_index:type_name -> common.CreatedIndexLog + 88, // 113: common.LedgerLogPayload.drop_index:type_name -> common.DroppedIndexLog + 140, // 114: common.LedgerLogPayload.added_account_type:type_name -> common.AddedAccountTypeLog + 141, // 115: common.LedgerLogPayload.removed_account_type:type_name -> common.RemovedAccountTypeLog + 142, // 116: common.LedgerLogPayload.updated_default_enforcement_mode:type_name -> common.UpdatedDefaultEnforcementModeLog + 86, // 117: common.LedgerLogPayload.order_skipped:type_name -> common.OrderSkippedLog + 11, // 118: common.OrderSkippedLog.reason:type_name -> common.ErrorReason + 187, // 119: common.OrderSkippedLog.context:type_name -> common.OrderSkippedLog.ContextEntry + 40, // 120: common.CreatedIndexLog.id:type_name -> common.IndexID + 40, // 121: common.DroppedIndexLog.id:type_name -> common.IndexID + 25, // 122: common.CreatedTransaction.transaction:type_name -> common.Transaction + 188, // 123: common.CreatedTransaction.account_metadata:type_name -> common.CreatedTransaction.AccountMetadataEntry + 31, // 124: common.CreatedTransaction.post_commit_volumes:type_name -> common.PostCommitVolumes + 25, // 125: common.RevertedTransaction.revert_transaction:type_name -> common.Transaction + 31, // 126: common.RevertedTransaction.post_commit_volumes:type_name -> common.PostCommitVolumes + 35, // 127: common.SavedMetadata.target:type_name -> common.Target + 189, // 128: common.SavedMetadata.metadata:type_name -> common.SavedMetadata.MetadataEntry + 35, // 129: common.DeletedMetadata.target:type_name -> common.Target + 0, // 130: common.SetMetadataFieldTypeLog.target_type:type_name -> common.TargetType + 1, // 131: common.SetMetadataFieldTypeLog.type:type_name -> common.MetadataType + 0, // 132: common.RemovedMetadataFieldTypeLog.target_type:type_name -> common.TargetType + 40, // 133: common.RemovedMetadataFieldTypeLog.dropped_index:type_name -> common.IndexID + 18, // 134: common.Chapter.start:type_name -> common.Timestamp + 18, // 135: common.Chapter.end:type_name -> common.Timestamp + 8, // 136: common.Chapter.status:type_name -> common.ChapterStatus + 96, // 137: common.ClosedChapterLog.closed_chapter:type_name -> common.Chapter + 96, // 138: common.ClosedChapterLog.new_chapter:type_name -> common.Chapter + 96, // 139: common.SealedChapterLog.chapter:type_name -> common.Chapter + 96, // 140: common.ArchivedChapterLog.chapter:type_name -> common.Chapter + 96, // 141: common.ConfirmedArchiveChapterLog.chapter:type_name -> common.Chapter + 121, // 142: common.MirrorSourceConfig.http:type_name -> common.HttpMirrorSourceConfig + 123, // 143: common.MirrorSourceConfig.postgres:type_name -> common.PostgresMirrorSourceConfig + 102, // 144: common.MirrorSourceConfig.rewrite_rules:type_name -> common.MirrorRewriteRule + 103, // 145: common.MirrorRewriteRule.created_transaction:type_name -> common.CreatedTransactionRule + 104, // 146: common.MirrorRewriteRule.reverted_transaction:type_name -> common.RevertedTransactionRule + 105, // 147: common.MirrorRewriteRule.saved_metadata:type_name -> common.SavedMetadataRule + 106, // 148: common.MirrorRewriteRule.deleted_metadata:type_name -> common.DeletedMetadataRule + 107, // 149: common.MirrorRewriteRule.any_variant:type_name -> common.AnyVariantRule + 108, // 150: common.CreatedTransactionRule.actions:type_name -> common.CreatedTransactionAction + 109, // 151: common.RevertedTransactionRule.actions:type_name -> common.RevertedTransactionAction + 110, // 152: common.SavedMetadataRule.actions:type_name -> common.SavedMetadataAction + 111, // 153: common.DeletedMetadataRule.actions:type_name -> common.DeletedMetadataAction + 112, // 154: common.AnyVariantRule.actions:type_name -> common.AnyVariantAction + 113, // 155: common.CreatedTransactionAction.rewrite_address:type_name -> common.RewriteAddressAction + 114, // 156: common.CreatedTransactionAction.set_metadata:type_name -> common.SetMetadataAction + 115, // 157: common.CreatedTransactionAction.delete_metadata:type_name -> common.DeleteMetadataAction + 116, // 158: common.CreatedTransactionAction.set_account_metadata:type_name -> common.SetAccountMetadataAction + 117, // 159: common.CreatedTransactionAction.delete_account_metadata:type_name -> common.DeleteAccountMetadataAction + 118, // 160: common.CreatedTransactionAction.set_account_metadata_from_address:type_name -> common.SetAccountMetadataFromAddressAction + 120, // 161: common.CreatedTransactionAction.drop:type_name -> common.DropAction + 113, // 162: common.RevertedTransactionAction.rewrite_address:type_name -> common.RewriteAddressAction + 114, // 163: common.RevertedTransactionAction.set_metadata:type_name -> common.SetMetadataAction + 115, // 164: common.RevertedTransactionAction.delete_metadata:type_name -> common.DeleteMetadataAction + 120, // 165: common.RevertedTransactionAction.drop:type_name -> common.DropAction + 113, // 166: common.SavedMetadataAction.rewrite_address:type_name -> common.RewriteAddressAction + 114, // 167: common.SavedMetadataAction.set_metadata:type_name -> common.SetMetadataAction + 115, // 168: common.SavedMetadataAction.delete_metadata:type_name -> common.DeleteMetadataAction + 120, // 169: common.SavedMetadataAction.drop:type_name -> common.DropAction + 113, // 170: common.DeletedMetadataAction.rewrite_address:type_name -> common.RewriteAddressAction + 120, // 171: common.DeletedMetadataAction.drop:type_name -> common.DropAction + 113, // 172: common.AnyVariantAction.rewrite_address:type_name -> common.RewriteAddressAction + 120, // 173: common.AnyVariantAction.drop:type_name -> common.DropAction + 119, // 174: common.SetAccountMetadataFromAddressAction.replacements:type_name -> common.SetAccountMetadataFromAddressReplacement + 122, // 175: common.HttpMirrorSourceConfig.oauth2_client_credentials:type_name -> common.OAuth2ClientCredentials + 124, // 176: common.PostgresMirrorSourceConfig.aws_iam_auth:type_name -> common.PostgresAwsIamAuth + 18, // 177: common.MirrorSyncError.occurred_at:type_name -> common.Timestamp + 10, // 178: common.MirrorSyncProgress.state:type_name -> common.MirrorSyncState + 125, // 179: common.MirrorSyncProgress.error:type_name -> common.MirrorSyncError + 18, // 180: common.LedgerInfo.created_at:type_name -> common.Timestamp + 18, // 181: common.LedgerInfo.deleted_at:type_name -> common.Timestamp + 37, // 182: common.LedgerInfo.metadata_schema:type_name -> common.MetadataSchema + 9, // 183: common.LedgerInfo.mode:type_name -> common.LedgerMode + 101, // 184: common.LedgerInfo.mirror_source:type_name -> common.MirrorSourceConfig + 126, // 185: common.LedgerInfo.mirror_sync_progress:type_name -> common.MirrorSyncProgress + 190, // 186: common.LedgerInfo.account_types:type_name -> common.LedgerInfo.AccountTypesEntry + 12, // 187: common.LedgerInfo.default_enforcement_mode:type_name -> common.ChartEnforcementMode + 191, // 188: common.LedgerInfo.metadata:type_name -> common.LedgerInfo.MetadataEntry + 35, // 189: common.SaveMetadataCommand.target:type_name -> common.Target + 192, // 190: common.SaveMetadataCommand.metadata:type_name -> common.SaveMetadataCommand.MetadataEntry + 35, // 191: common.DeleteMetadataCommand.target:type_name -> common.Target + 193, // 192: common.TransactionState.metadata:type_name -> common.TransactionState.MetadataEntry + 18, // 193: common.TransactionState.timestamp:type_name -> common.Timestamp + 24, // 194: common.TransactionState.postings:type_name -> common.Posting + 18, // 195: common.TransactionState.reverted_at:type_name -> common.Timestamp + 132, // 196: common.IdempotencyKeyValue.failure:type_name -> common.IdempotencyFailure + 11, // 197: common.IdempotencyFailure.reason:type_name -> common.ErrorReason + 194, // 198: common.IdempotencyFailure.metadata:type_name -> common.IdempotencyFailure.MetadataEntry + 136, // 199: common.SegmentType.uuid:type_name -> common.UUIDConstraint + 137, // 200: common.SegmentType.uint64:type_name -> common.Uint64Constraint + 138, // 201: common.SegmentType.bytes:type_name -> common.BytesConstraint + 13, // 202: common.AccountType.persistence:type_name -> common.AccountTypePersistence + 195, // 203: common.AccountType.segment_types:type_name -> common.AccountType.SegmentTypesEntry + 139, // 204: common.AddedAccountTypeLog.account_type:type_name -> common.AccountType + 12, // 205: common.UpdatedDefaultEnforcementModeLog.enforcement_mode:type_name -> common.ChartEnforcementMode + 156, // 206: common.QueryFilter.field:type_name -> common.FieldCondition + 162, // 207: common.QueryFilter.address:type_name -> common.AddressMatch + 152, // 208: common.QueryFilter.and:type_name -> common.AndFilter + 153, // 209: common.QueryFilter.or:type_name -> common.OrFilter + 154, // 210: common.QueryFilter.not:type_name -> common.NotFilter + 144, // 211: common.QueryFilter.reference:type_name -> common.ReferenceCondition + 149, // 212: common.QueryFilter.builtin_uint:type_name -> common.BuiltinUintCondition + 147, // 213: common.QueryFilter.ledger:type_name -> common.LedgerCondition + 148, // 214: common.QueryFilter.log_id:type_name -> common.LogIdCondition + 150, // 215: common.QueryFilter.log_builtin_uint:type_name -> common.LogBuiltinUintCondition + 151, // 216: common.QueryFilter.account_has_asset:type_name -> common.AccountHasAssetCondition + 145, // 217: common.QueryFilter.reverted:type_name -> common.RevertedCondition + 146, // 218: common.QueryFilter.audit:type_name -> common.AuditCondition + 157, // 219: common.ReferenceCondition.cond:type_name -> common.StringCondition + 14, // 220: common.AuditCondition.field:type_name -> common.AuditField + 157, // 221: common.AuditCondition.string_cond:type_name -> common.StringCondition + 159, // 222: common.AuditCondition.uint_cond:type_name -> common.UintCondition + 157, // 223: common.LedgerCondition.cond:type_name -> common.StringCondition + 159, // 224: common.LogIdCondition.cond:type_name -> common.UintCondition + 3, // 225: common.BuiltinUintCondition.field:type_name -> common.TransactionBuiltinIndex + 159, // 226: common.BuiltinUintCondition.cond:type_name -> common.UintCondition + 5, // 227: common.LogBuiltinUintCondition.field:type_name -> common.LogBuiltinIndex + 159, // 228: common.LogBuiltinUintCondition.cond:type_name -> common.UintCondition + 143, // 229: common.AndFilter.filters:type_name -> common.QueryFilter + 143, // 230: common.OrFilter.filters:type_name -> common.QueryFilter + 143, // 231: common.NotFilter.filter:type_name -> common.QueryFilter + 155, // 232: common.FieldCondition.field:type_name -> common.FieldRef + 157, // 233: common.FieldCondition.string_cond:type_name -> common.StringCondition + 158, // 234: common.FieldCondition.int_cond:type_name -> common.IntCondition + 159, // 235: common.FieldCondition.uint_cond:type_name -> common.UintCondition + 160, // 236: common.FieldCondition.bool_cond:type_name -> common.BoolCondition + 161, // 237: common.FieldCondition.exists_cond:type_name -> common.ExistsCondition + 15, // 238: common.AddressMatch.role:type_name -> common.AddressRole + 143, // 239: common.PreparedQuery.filter:type_name -> common.QueryFilter + 16, // 240: common.PreparedQuery.target:type_name -> common.QueryTarget + 23, // 241: common.AggregatedVolume.input:type_name -> common.Uint256 + 23, // 242: common.AggregatedVolume.output:type_name -> common.Uint256 + 164, // 243: common.AggregateResult.volumes:type_name -> common.AggregatedVolume + 166, // 244: common.AggregateResult.groups:type_name -> common.GroupedAggregateResult + 164, // 245: common.GroupedAggregateResult.volumes:type_name -> common.AggregatedVolume + 33, // 246: common.PreparedQueryCursor.account_data:type_name -> common.Account + 25, // 247: common.PreparedQueryCursor.transaction_data:type_name -> common.Transaction + 44, // 248: common.PreparedQueryCursor.log_data:type_name -> common.Log + 170, // 249: common.CallerSnapshot.identity:type_name -> common.CallerIdentity + 172, // 250: common.BackupStorage.s3:type_name -> common.S3StorageConfig + 173, // 251: common.BackupStorage.azure:type_name -> common.AzureStorageConfig + 175, // 252: common.ListOptions.read:type_name -> common.ReadOptions + 143, // 253: common.ListOptions.filter:type_name -> common.QueryFilter + 20, // 254: common.MetadataMap.ValuesEntry.value:type_name -> common.MetadataValue + 20, // 255: common.Transaction.MetadataEntry.value:type_name -> common.MetadataValue + 29, // 256: common.PostCommitVolumes.VolumesByAccountEntry.value:type_name -> common.VolumesByAssets + 20, // 257: common.Account.MetadataEntry.value:type_name -> common.MetadataValue + 36, // 258: common.MetadataSchema.AccountFieldsEntry.value:type_name -> common.MetadataFieldSchema + 36, // 259: common.MetadataSchema.TransactionFieldsEntry.value:type_name -> common.MetadataFieldSchema + 36, // 260: common.MetadataSchema.LedgerFieldsEntry.value:type_name -> common.MetadataFieldSchema 20, // 261: common.SavedLedgerMetadataLog.MetadataEntry.value:type_name -> common.MetadataValue - 137, // 262: common.CreatedLedgerLog.AccountTypesEntry.value:type_name -> common.AccountType + 139, // 262: common.CreatedLedgerLog.AccountTypesEntry.value:type_name -> common.AccountType 21, // 263: common.CreatedTransaction.AccountMetadataEntry.value:type_name -> common.MetadataMap 20, // 264: common.SavedMetadata.MetadataEntry.value:type_name -> common.MetadataValue - 137, // 265: common.LedgerInfo.AccountTypesEntry.value:type_name -> common.AccountType + 139, // 265: common.LedgerInfo.AccountTypesEntry.value:type_name -> common.AccountType 20, // 266: common.LedgerInfo.MetadataEntry.value:type_name -> common.MetadataValue 20, // 267: common.SaveMetadataCommand.MetadataEntry.value:type_name -> common.MetadataValue 20, // 268: common.TransactionState.MetadataEntry.value:type_name -> common.MetadataValue - 133, // 269: common.AccountType.SegmentTypesEntry.value:type_name -> common.SegmentType + 135, // 269: common.AccountType.SegmentTypesEntry.value:type_name -> common.SegmentType 197, // 270: common.allowed_query_targets:extendee -> google.protobuf.FieldOptions 197, // 271: common.valid_on_no_query_target:extendee -> google.protobuf.FieldOptions 16, // 272: common.allowed_query_targets:type_name -> common.QueryTarget @@ -14129,17 +14304,17 @@ func file_common_proto_init() { (*ParameterValue_Uint64Value)(nil), (*ParameterValue_BoolValue)(nil), } - file_common_proto_msgTypes[15].OneofWrappers = []any{ + file_common_proto_msgTypes[17].OneofWrappers = []any{ (*Target_Account)(nil), (*Target_TransactionId)(nil), } - file_common_proto_msgTypes[20].OneofWrappers = []any{ + file_common_proto_msgTypes[22].OneofWrappers = []any{ (*IndexID_TxBuiltin)(nil), (*IndexID_LogBuiltin)(nil), (*IndexID_AccountBuiltin)(nil), (*IndexID_Metadata)(nil), } - file_common_proto_msgTypes[25].OneofWrappers = []any{ + file_common_proto_msgTypes[27].OneofWrappers = []any{ (*LogPayload_CreateLedger)(nil), (*LogPayload_DeleteLedger)(nil), (*LogPayload_Apply)(nil), @@ -14168,18 +14343,18 @@ func file_common_proto_init() { (*LogPayload_SavedLedgerMetadata)(nil), (*LogPayload_DeletedLedgerMetadata)(nil), } - file_common_proto_msgTypes[51].OneofWrappers = []any{ + file_common_proto_msgTypes[53].OneofWrappers = []any{ (*SinkConfig_Nats)(nil), (*SinkConfig_Clickhouse)(nil), (*SinkConfig_Kafka)(nil), (*SinkConfig_Http)(nil), (*SinkConfig_Databricks)(nil), } - file_common_proto_msgTypes[58].OneofWrappers = []any{ + file_common_proto_msgTypes[60].OneofWrappers = []any{ (*DatabricksSinkConfig_Token)(nil), (*DatabricksSinkConfig_OauthM2M)(nil), } - file_common_proto_msgTypes[65].OneofWrappers = []any{ + file_common_proto_msgTypes[67].OneofWrappers = []any{ (*LedgerLogPayload_CreatedTransaction)(nil), (*LedgerLogPayload_RevertedTransaction)(nil), (*LedgerLogPayload_SavedMetadata)(nil), @@ -14194,18 +14369,18 @@ func file_common_proto_init() { (*LedgerLogPayload_UpdatedDefaultEnforcementMode)(nil), (*LedgerLogPayload_OrderSkipped)(nil), } - file_common_proto_msgTypes[81].OneofWrappers = []any{ + file_common_proto_msgTypes[83].OneofWrappers = []any{ (*MirrorSourceConfig_Http)(nil), (*MirrorSourceConfig_Postgres)(nil), } - file_common_proto_msgTypes[82].OneofWrappers = []any{ + file_common_proto_msgTypes[84].OneofWrappers = []any{ (*MirrorRewriteRule_CreatedTransaction)(nil), (*MirrorRewriteRule_RevertedTransaction)(nil), (*MirrorRewriteRule_SavedMetadata)(nil), (*MirrorRewriteRule_DeletedMetadata)(nil), (*MirrorRewriteRule_AnyVariant)(nil), } - file_common_proto_msgTypes[88].OneofWrappers = []any{ + file_common_proto_msgTypes[90].OneofWrappers = []any{ (*CreatedTransactionAction_RewriteAddress)(nil), (*CreatedTransactionAction_SetMetadata)(nil), (*CreatedTransactionAction_DeleteMetadata)(nil), @@ -14214,41 +14389,41 @@ func file_common_proto_init() { (*CreatedTransactionAction_SetAccountMetadataFromAddress)(nil), (*CreatedTransactionAction_Drop)(nil), } - file_common_proto_msgTypes[89].OneofWrappers = []any{ + file_common_proto_msgTypes[91].OneofWrappers = []any{ (*RevertedTransactionAction_RewriteAddress)(nil), (*RevertedTransactionAction_SetMetadata)(nil), (*RevertedTransactionAction_DeleteMetadata)(nil), (*RevertedTransactionAction_Drop)(nil), } - file_common_proto_msgTypes[90].OneofWrappers = []any{ + file_common_proto_msgTypes[92].OneofWrappers = []any{ (*SavedMetadataAction_RewriteAddress)(nil), (*SavedMetadataAction_SetMetadata)(nil), (*SavedMetadataAction_DeleteMetadata)(nil), (*SavedMetadataAction_Drop)(nil), } - file_common_proto_msgTypes[91].OneofWrappers = []any{ + file_common_proto_msgTypes[93].OneofWrappers = []any{ (*DeletedMetadataAction_RewriteAddress)(nil), (*DeletedMetadataAction_Drop)(nil), } - file_common_proto_msgTypes[92].OneofWrappers = []any{ + file_common_proto_msgTypes[94].OneofWrappers = []any{ (*AnyVariantAction_RewriteAddress)(nil), (*AnyVariantAction_Drop)(nil), } - file_common_proto_msgTypes[94].OneofWrappers = []any{ + file_common_proto_msgTypes[96].OneofWrappers = []any{ (*SetMetadataAction_Value)(nil), (*SetMetadataAction_ValueExpr)(nil), } - file_common_proto_msgTypes[96].OneofWrappers = []any{ + file_common_proto_msgTypes[98].OneofWrappers = []any{ (*SetAccountMetadataAction_Value)(nil), (*SetAccountMetadataAction_ValueExpr)(nil), } - file_common_proto_msgTypes[115].OneofWrappers = []any{ + file_common_proto_msgTypes[117].OneofWrappers = []any{ (*SegmentType_Regex)(nil), (*SegmentType_Uuid)(nil), (*SegmentType_Uint64)(nil), (*SegmentType_Bytes)(nil), } - file_common_proto_msgTypes[123].OneofWrappers = []any{ + file_common_proto_msgTypes[125].OneofWrappers = []any{ (*QueryFilter_Field)(nil), (*QueryFilter_Address)(nil), (*QueryFilter_And)(nil), @@ -14263,39 +14438,39 @@ func file_common_proto_init() { (*QueryFilter_Reverted)(nil), (*QueryFilter_Audit)(nil), } - file_common_proto_msgTypes[126].OneofWrappers = []any{ + file_common_proto_msgTypes[128].OneofWrappers = []any{ (*AuditCondition_StringCond)(nil), (*AuditCondition_UintCond)(nil), } - file_common_proto_msgTypes[136].OneofWrappers = []any{ + file_common_proto_msgTypes[138].OneofWrappers = []any{ (*FieldCondition_StringCond)(nil), (*FieldCondition_IntCond)(nil), (*FieldCondition_UintCond)(nil), (*FieldCondition_BoolCond)(nil), (*FieldCondition_ExistsCond)(nil), } - file_common_proto_msgTypes[137].OneofWrappers = []any{ + file_common_proto_msgTypes[139].OneofWrappers = []any{ (*StringCondition_Hardcoded)(nil), (*StringCondition_Param)(nil), } - file_common_proto_msgTypes[138].OneofWrappers = []any{} - file_common_proto_msgTypes[139].OneofWrappers = []any{} - file_common_proto_msgTypes[140].OneofWrappers = []any{ + file_common_proto_msgTypes[140].OneofWrappers = []any{} + file_common_proto_msgTypes[141].OneofWrappers = []any{} + file_common_proto_msgTypes[142].OneofWrappers = []any{ (*BoolCondition_Hardcoded)(nil), (*BoolCondition_Param)(nil), } - file_common_proto_msgTypes[142].OneofWrappers = []any{ + file_common_proto_msgTypes[144].OneofWrappers = []any{ (*AddressMatch_HardcodedPrefix)(nil), (*AddressMatch_HardcodedExact)(nil), (*AddressMatch_ParamPrefix)(nil), (*AddressMatch_ParamExact)(nil), } - file_common_proto_msgTypes[150].OneofWrappers = []any{ + file_common_proto_msgTypes[152].OneofWrappers = []any{ (*CallerIdentity_Issuer)(nil), (*CallerIdentity_KeyId)(nil), (*CallerIdentity_SystemComponent)(nil), } - file_common_proto_msgTypes[154].OneofWrappers = []any{ + file_common_proto_msgTypes[156].OneofWrappers = []any{ (*BackupStorage_S3)(nil), (*BackupStorage_Azure)(nil), } diff --git a/internal/proto/commonpb/common.pb.json.go b/internal/proto/commonpb/common.pb.json.go index 2f60375a7d..be714b101c 100644 --- a/internal/proto/commonpb/common.pb.json.go +++ b/internal/proto/commonpb/common.pb.json.go @@ -188,17 +188,19 @@ func (x *OrderSkippedLog) UnmarshalJSON(data []byte) error { } // MarshalJSON implements json.Marshaler for PostCommitVolumes. The wire shape -// is a flat `{address: {asset: Volumes}}` map — protojson would emit the raw -// proto wrappers (`volumesByAccount.{addr}.volumes.{asset}`), leaking two -// nesting levels that don't belong on the public API and don't match the -// OpenAPI schema (see PostCommitVolumes in openapi.yml). +// is a flat `{"addr": [{asset, color, input, output}]}` map — one array of +// (asset, color) tuples per account. protojson would otherwise emit the raw +// proto wrappers (`{"volumesByAccount": {"addr": {"volumes": [...]}}}`) two +// levels deep. This replaces the pre-color EN-1465 `{"addr": {"asset": Volumes}}` +// map shape, which can no longer key a bucket uniquely once a color dimension +// exists, while keeping the flatten intent (no protojson wrappers). func (x *PostCommitVolumes) MarshalJSON() ([]byte, error) { byAccount := x.GetVolumesByAccount() if len(byAccount) == 0 { return []byte("{}"), nil } - flat := make(map[string]map[string]*Volumes, len(byAccount)) + flat := make(map[string][]*VolumeEntry, len(byAccount)) for addr, va := range byAccount { flat[addr] = va.GetVolumes() } @@ -210,19 +212,61 @@ func (x *PostCommitVolumes) MarshalJSON() ([]byte, error) { // the server emits it, no request payload ever carries it, so there is no // production caller for reverse conversion. Client-side consumers wanting to // parse the flat shape can decode straight into a -// `map[string]map[string]Volumes` — the same structure MarshalJSON emits. +// `map[string][]VolumeEntry` — the same structure MarshalJSON emits, where each +// VolumeEntry is `{asset, color, input, output}`. + +// MarshalJSON implements json.Marshaler for VolumeEntry. Color is always +// emitted (even when empty) so clients can distinguish the uncolored bucket +// from an older response shape — same contract as accountVolumeJSON and +// aggregatedVolumeJSON in the REST handler layer. Input/output are flattened +// onto the tuple (not nested under a `volumes` key) so a post-commit-volume +// entry reads as one flat `{asset, color, input, output}` row. +func (x *VolumeEntry) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Asset string `json:"asset"` + Color string `json:"color"` + Input string `json:"input"` + Output string `json:"output"` + }{ + Asset: x.GetAsset(), + Color: x.GetColor(), + Input: x.GetVolumes().GetInput(), + Output: x.GetVolumes().GetOutput(), + }) +} + +// accountVolumeJSON is the JSON shape for AccountVolume. Color is always +// emitted (even empty) because the API treats the empty bucket as a +// first-class entry and clients cannot otherwise tell "uncolored bucket" +// from "field absent in an older response shape". +type accountVolumeJSON struct { + Asset string `json:"asset"` + Color string `json:"color"` + Volumes *VolumesWithBalance `json:"volumes,omitempty"` +} // MarshalJSON implements json.Marshaler for Account. func (x *Account) MarshalJSON() ([]byte, error) { + volumes := make([]*accountVolumeJSON, 0, len(x.GetVolumes())) + for _, v := range x.GetVolumes() { + volumes = append(volumes, &accountVolumeJSON{ + Asset: v.GetAsset(), + Color: v.GetColor(), + Volumes: v.GetVolumes(), + }) + } + return json.Marshal(&struct { - Address string `json:"address,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - FirstUsage *Timestamp `json:"firstUsage,omitempty"` - InsertionDate *Timestamp `json:"insertionDate,omitempty"` - UpdatedAt *Timestamp `json:"updatedAt,omitempty"` + Address string `json:"address,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Volumes []*accountVolumeJSON `json:"volumes"` + FirstUsage *Timestamp `json:"firstUsage,omitempty"` + InsertionDate *Timestamp `json:"insertionDate,omitempty"` + UpdatedAt *Timestamp `json:"updatedAt,omitempty"` }{ Address: x.GetAddress(), Metadata: MetadataToAnyMap(x.GetMetadata()), + Volumes: volumes, FirstUsage: x.GetFirstUsage(), InsertionDate: x.GetInsertionDate(), UpdatedAt: x.GetUpdatedAt(), diff --git a/internal/proto/commonpb/common_dethash.pb.go b/internal/proto/commonpb/common_dethash.pb.go index a86d2abde2..e8537f1ee7 100644 --- a/internal/proto/commonpb/common_dethash.pb.go +++ b/internal/proto/commonpb/common_dethash.pb.go @@ -350,50 +350,22 @@ func (m *VolumesByAssets) MarshalDeterministicVT(dAtA []byte) []byte { if m == nil { return dAtA } - sz := m.SizeVT() - buf := make([]byte, sz) - n, _ := m.MarshalToSizedBufferDeterministicVT(buf) - return append(dAtA, buf[sz-n:]...) + b, err := m.MarshalVT() + if err != nil { + panic("MarshalDeterministicVT: " + err.Error()) + } + return append(dAtA, b...) } -func (m *VolumesByAssets) MarshalToSizedBufferDeterministicVT(dAtA []byte) (int, error) { +func (m *VolumeEntry) MarshalDeterministicVT(dAtA []byte) []byte { if m == nil { - return 0, nil - } - i := len(dAtA) - if m.unknownFields != nil { - i -= len(m.unknownFields) - copy(dAtA[i:], m.unknownFields) + return dAtA } - if len(m.Volumes) > 0 { - keysPtr := _dethashKeyPoolGithubComFormancehqLedgerV3InternalProtoCommonpbCommonString.Get().(*[]string) - keys := (*keysPtr)[:0] - for k := range m.Volumes { - keys = append(keys, k) - } - slices.Sort(keys) - for _, k := range keys { - v := m.Volumes[k] - baseI := i - size, _ := v.MarshalToSizedBufferVT(dAtA[:i]) - i -= size - i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) - i-- - dAtA[i] = 0x12 - i -= len(k) - copy(dAtA[i:], k) - i = protohelpers.EncodeVarint(dAtA, i, uint64(len(k))) - i-- - dAtA[i] = 0xa - i = protohelpers.EncodeVarint(dAtA, i, uint64(baseI-i)) - i-- - dAtA[i] = 0xa - } - clear(keys) - *keysPtr = keys - _dethashKeyPoolGithubComFormancehqLedgerV3InternalProtoCommonpbCommonString.Put(keysPtr) + b, err := m.MarshalVT() + if err != nil { + panic("MarshalDeterministicVT: " + err.Error()) } - return len(dAtA) - i, nil + return append(dAtA, b...) } func (m *PostCommitVolumes) MarshalDeterministicVT(dAtA []byte) []byte { @@ -425,7 +397,7 @@ func (m *PostCommitVolumes) MarshalToSizedBufferDeterministicVT(dAtA []byte) (in for _, k := range keys { v := m.VolumesByAccount[k] baseI := i - size, _ := v.MarshalToSizedBufferDeterministicVT(dAtA[:i]) + size, _ := v.MarshalToSizedBufferVT(dAtA[:i]) i -= size i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) i-- @@ -446,6 +418,17 @@ func (m *PostCommitVolumes) MarshalToSizedBufferDeterministicVT(dAtA []byte) (in return len(dAtA) - i, nil } +func (m *AccountVolume) MarshalDeterministicVT(dAtA []byte) []byte { + if m == nil { + return dAtA + } + b, err := m.MarshalVT() + if err != nil { + panic("MarshalDeterministicVT: " + err.Error()) + } + return append(dAtA, b...) +} + func (m *Account) MarshalDeterministicVT(dAtA []byte) []byte { if m == nil { return dAtA @@ -466,32 +449,13 @@ func (m *Account) MarshalToSizedBufferDeterministicVT(dAtA []byte) (int, error) copy(dAtA[i:], m.unknownFields) } if len(m.Volumes) > 0 { - keysPtr := _dethashKeyPoolGithubComFormancehqLedgerV3InternalProtoCommonpbCommonString.Get().(*[]string) - keys := (*keysPtr)[:0] - for k := range m.Volumes { - keys = append(keys, k) - } - slices.Sort(keys) - for _, k := range keys { - v := m.Volumes[k] - baseI := i - size, _ := v.MarshalToSizedBufferVT(dAtA[:i]) + for iNdEx := len(m.Volumes) - 1; iNdEx >= 0; iNdEx-- { + size, _ := m.Volumes[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) i -= size i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) i-- - dAtA[i] = 0x12 - i -= len(k) - copy(dAtA[i:], k) - i = protohelpers.EncodeVarint(dAtA, i, uint64(len(k))) - i-- - dAtA[i] = 0xa - i = protohelpers.EncodeVarint(dAtA, i, uint64(baseI-i)) - i-- dAtA[i] = 0x32 } - clear(keys) - *keysPtr = keys - _dethashKeyPoolGithubComFormancehqLedgerV3InternalProtoCommonpbCommonString.Put(keysPtr) } if m.UpdatedAt != nil { size, _ := m.UpdatedAt.MarshalToSizedBufferVT(dAtA[:i]) diff --git a/internal/proto/commonpb/common_reader.pb.go b/internal/proto/commonpb/common_reader.pb.go index d182c24a9f..ce66dfea7b 100644 --- a/internal/proto/commonpb/common_reader.pb.go +++ b/internal/proto/commonpb/common_reader.pb.go @@ -469,6 +469,7 @@ type PostingReader interface { GetDestination() string GetAmount() Uint256Reader GetAsset() string + GetColor() string Mutate() *Posting } @@ -494,6 +495,10 @@ func (r *postingReadonly) GetAsset() string { return (*Posting)(r).GetAsset() } +func (r *postingReadonly) GetColor() string { + return (*Posting)(r).GetColor() +} + func (r *postingReadonly) Mutate() *Posting { return (*Posting)(r).CloneVT() } @@ -959,14 +964,14 @@ func NewVolumesWithBalanceListReader(s []*VolumesWithBalance) VolumesWithBalance // VolumesByAssetsReader provides read-only access to VolumesByAssets. // Call Mutate() to obtain a mutable clone. type VolumesByAssetsReader interface { - GetVolumes() VolumesByAssets_VolumesMapReader + GetVolumes() VolumeEntryListReader Mutate() *VolumesByAssets } type volumesByAssetsReadonly VolumesByAssets -func (r *volumesByAssetsReadonly) GetVolumes() VolumesByAssets_VolumesMapReader { - return volumesByAssets_volumesMapReadonly((*VolumesByAssets)(r).GetVolumes()) +func (r *volumesByAssetsReadonly) GetVolumes() VolumeEntryListReader { + return NewVolumeEntryListReader((*VolumesByAssets)(r).GetVolumes()) } func (r *volumesByAssetsReadonly) Mutate() *VolumesByAssets { @@ -1023,37 +1028,87 @@ func NewVolumesByAssetsListReader(s []*VolumesByAssets) VolumesByAssetsListReade return volumesByAssetsListReadonly(s) } -// VolumesByAssets_VolumesMapReader provides read-only access to VolumesByAssets.Volumes. -type VolumesByAssets_VolumesMapReader interface { +// VolumeEntryReader provides read-only access to VolumeEntry. +// Call Mutate() to obtain a mutable clone. +type VolumeEntryReader interface { + GetAsset() string + GetColor() string + GetVolumes() VolumesReader + Mutate() *VolumeEntry +} + +type volumeEntryReadonly VolumeEntry + +func (r *volumeEntryReadonly) GetAsset() string { + return (*VolumeEntry)(r).GetAsset() +} + +func (r *volumeEntryReadonly) GetColor() string { + return (*VolumeEntry)(r).GetColor() +} + +func (r *volumeEntryReadonly) GetVolumes() VolumesReader { + v := (*VolumeEntry)(r).GetVolumes() + if v == nil { + return nil + } + return v.AsReader() +} + +func (r *volumeEntryReadonly) Mutate() *VolumeEntry { + return (*VolumeEntry)(r).CloneVT() +} + +// AsReader returns a read-only view of this VolumeEntry. +func (m *VolumeEntry) AsReader() VolumeEntryReader { + if m == nil { + return nil + } + return (*volumeEntryReadonly)(m) +} + +// Mutate returns a mutable deep clone of this VolumeEntry. +func (m *VolumeEntry) Mutate() *VolumeEntry { + return m.CloneVT() +} + +// VolumeEntryListReader provides read-only iteration over []*VolumeEntry. +type VolumeEntryListReader interface { Len() int - Get(k string) (VolumesReader, bool) - Range(yield func(string, VolumesReader) bool) + Get(i int) VolumeEntryReader + Range(yield func(int, VolumeEntryReader) bool) } -type volumesByAssets_volumesMapReadonly map[string]*Volumes +type volumeEntryListReadonly []*VolumeEntry -func (m volumesByAssets_volumesMapReadonly) Len() int { return len(m) } +func (l volumeEntryListReadonly) Len() int { return len(l) } -func (m volumesByAssets_volumesMapReadonly) Get(k string) (VolumesReader, bool) { - v, ok := m[k] - if !ok || v == nil { - return nil, ok +func (l volumeEntryListReadonly) Get(i int) VolumeEntryReader { + v := l[i] + if v == nil { + return nil } - return v.AsReader(), true + return v.AsReader() } -func (m volumesByAssets_volumesMapReadonly) Range(yield func(string, VolumesReader) bool) { - for k, v := range m { - var r VolumesReader +func (l volumeEntryListReadonly) Range(yield func(int, VolumeEntryReader) bool) { + for i, v := range l { + var r VolumeEntryReader if v != nil { r = v.AsReader() } - if !yield(k, r) { + if !yield(i, r) { return } } } +// NewVolumeEntryListReader wraps s for read-only iteration. The returned +// view aliases the underlying slice; do not mutate s afterwards. +func NewVolumeEntryListReader(s []*VolumeEntry) VolumeEntryListReader { + return volumeEntryListReadonly(s) +} + // PostCommitVolumesReader provides read-only access to PostCommitVolumes. // Call Mutate() to obtain a mutable clone. type PostCommitVolumesReader interface { @@ -1152,6 +1207,87 @@ func (m postCommitVolumes_volumesByAccountMapReadonly) Range(yield func(string, } } +// AccountVolumeReader provides read-only access to AccountVolume. +// Call Mutate() to obtain a mutable clone. +type AccountVolumeReader interface { + GetAsset() string + GetColor() string + GetVolumes() VolumesWithBalanceReader + Mutate() *AccountVolume +} + +type accountVolumeReadonly AccountVolume + +func (r *accountVolumeReadonly) GetAsset() string { + return (*AccountVolume)(r).GetAsset() +} + +func (r *accountVolumeReadonly) GetColor() string { + return (*AccountVolume)(r).GetColor() +} + +func (r *accountVolumeReadonly) GetVolumes() VolumesWithBalanceReader { + v := (*AccountVolume)(r).GetVolumes() + if v == nil { + return nil + } + return v.AsReader() +} + +func (r *accountVolumeReadonly) Mutate() *AccountVolume { + return (*AccountVolume)(r).CloneVT() +} + +// AsReader returns a read-only view of this AccountVolume. +func (m *AccountVolume) AsReader() AccountVolumeReader { + if m == nil { + return nil + } + return (*accountVolumeReadonly)(m) +} + +// Mutate returns a mutable deep clone of this AccountVolume. +func (m *AccountVolume) Mutate() *AccountVolume { + return m.CloneVT() +} + +// AccountVolumeListReader provides read-only iteration over []*AccountVolume. +type AccountVolumeListReader interface { + Len() int + Get(i int) AccountVolumeReader + Range(yield func(int, AccountVolumeReader) bool) +} + +type accountVolumeListReadonly []*AccountVolume + +func (l accountVolumeListReadonly) Len() int { return len(l) } + +func (l accountVolumeListReadonly) Get(i int) AccountVolumeReader { + v := l[i] + if v == nil { + return nil + } + return v.AsReader() +} + +func (l accountVolumeListReadonly) Range(yield func(int, AccountVolumeReader) bool) { + for i, v := range l { + var r AccountVolumeReader + if v != nil { + r = v.AsReader() + } + if !yield(i, r) { + return + } + } +} + +// NewAccountVolumeListReader wraps s for read-only iteration. The returned +// view aliases the underlying slice; do not mutate s afterwards. +func NewAccountVolumeListReader(s []*AccountVolume) AccountVolumeListReader { + return accountVolumeListReadonly(s) +} + // AccountReader provides read-only access to Account. // Call Mutate() to obtain a mutable clone. type AccountReader interface { @@ -1160,7 +1296,7 @@ type AccountReader interface { GetFirstUsage() TimestampReader GetInsertionDate() TimestampReader GetUpdatedAt() TimestampReader - GetVolumes() Account_VolumesMapReader + GetVolumes() AccountVolumeListReader Mutate() *Account } @@ -1198,8 +1334,8 @@ func (r *accountReadonly) GetUpdatedAt() TimestampReader { return v.AsReader() } -func (r *accountReadonly) GetVolumes() Account_VolumesMapReader { - return account_volumesMapReadonly((*Account)(r).GetVolumes()) +func (r *accountReadonly) GetVolumes() AccountVolumeListReader { + return NewAccountVolumeListReader((*Account)(r).GetVolumes()) } func (r *accountReadonly) Mutate() *Account { @@ -1285,37 +1421,6 @@ func (m account_metadataMapReadonly) Range(yield func(string, MetadataValueReade } } -// Account_VolumesMapReader provides read-only access to Account.Volumes. -type Account_VolumesMapReader interface { - Len() int - Get(k string) (VolumesWithBalanceReader, bool) - Range(yield func(string, VolumesWithBalanceReader) bool) -} - -type account_volumesMapReadonly map[string]*VolumesWithBalance - -func (m account_volumesMapReadonly) Len() int { return len(m) } - -func (m account_volumesMapReadonly) Get(k string) (VolumesWithBalanceReader, bool) { - v, ok := m[k] - if !ok || v == nil { - return nil, ok - } - return v.AsReader(), true -} - -func (m account_volumesMapReadonly) Range(yield func(string, VolumesWithBalanceReader) bool) { - for k, v := range m { - var r VolumesWithBalanceReader - if v != nil { - r = v.AsReader() - } - if !yield(k, r) { - return - } - } -} - // TargetAccountReader provides read-only access to TargetAccount. // Call Mutate() to obtain a mutable clone. type TargetAccountReader interface { @@ -5343,6 +5448,7 @@ func NewLedgerLogListReader(s []*LedgerLog) LedgerLogListReader { return ledgerL type TouchedVolumeReader interface { GetAccount() string GetAsset() string + GetColor() string Mutate() *TouchedVolume } @@ -5356,6 +5462,10 @@ func (r *touchedVolumeReadonly) GetAsset() string { return (*TouchedVolume)(r).GetAsset() } +func (r *touchedVolumeReadonly) GetColor() string { + return (*TouchedVolume)(r).GetColor() +} + func (r *touchedVolumeReadonly) Mutate() *TouchedVolume { return (*TouchedVolume)(r).CloneVT() } @@ -11591,6 +11701,7 @@ type AggregatedVolumeReader interface { GetAsset() string GetInput() Uint256Reader GetOutput() Uint256Reader + GetColor() string Mutate() *AggregatedVolume } @@ -11616,6 +11727,10 @@ func (r *aggregatedVolumeReadonly) GetOutput() Uint256Reader { return v.AsReader() } +func (r *aggregatedVolumeReadonly) GetColor() string { + return (*AggregatedVolume)(r).GetColor() +} + func (r *aggregatedVolumeReadonly) Mutate() *AggregatedVolume { return (*AggregatedVolume)(r).CloneVT() } diff --git a/internal/proto/commonpb/common_vtproto.pb.go b/internal/proto/commonpb/common_vtproto.pb.go index e2d5daa453..a5c523911d 100644 --- a/internal/proto/commonpb/common_vtproto.pb.go +++ b/internal/proto/commonpb/common_vtproto.pb.go @@ -236,6 +236,7 @@ func (m *Posting) CloneVT() *Posting { r.Destination = m.Destination r.Amount = m.Amount.CloneVT() r.Asset = m.Asset + r.Color = m.Color if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -358,7 +359,7 @@ func (m *VolumesByAssets) CloneVT() *VolumesByAssets { } r := new(VolumesByAssets) if rhs := m.Volumes; rhs != nil { - tmpContainer := make(map[string]*Volumes, len(rhs)) + tmpContainer := make([]*VolumeEntry, len(rhs)) for k, v := range rhs { tmpContainer[k] = v.CloneVT() } @@ -375,6 +376,25 @@ func (m *VolumesByAssets) CloneMessageVT() proto.Message { return m.CloneVT() } +func (m *VolumeEntry) CloneVT() *VolumeEntry { + if m == nil { + return (*VolumeEntry)(nil) + } + r := new(VolumeEntry) + r.Asset = m.Asset + r.Color = m.Color + r.Volumes = m.Volumes.CloneVT() + if len(m.unknownFields) > 0 { + r.unknownFields = make([]byte, len(m.unknownFields)) + copy(r.unknownFields, m.unknownFields) + } + return r +} + +func (m *VolumeEntry) CloneMessageVT() proto.Message { + return m.CloneVT() +} + func (m *PostCommitVolumes) CloneVT() *PostCommitVolumes { if m == nil { return (*PostCommitVolumes)(nil) @@ -398,6 +418,25 @@ func (m *PostCommitVolumes) CloneMessageVT() proto.Message { return m.CloneVT() } +func (m *AccountVolume) CloneVT() *AccountVolume { + if m == nil { + return (*AccountVolume)(nil) + } + r := new(AccountVolume) + r.Asset = m.Asset + r.Color = m.Color + r.Volumes = m.Volumes.CloneVT() + if len(m.unknownFields) > 0 { + r.unknownFields = make([]byte, len(m.unknownFields)) + copy(r.unknownFields, m.unknownFields) + } + return r +} + +func (m *AccountVolume) CloneMessageVT() proto.Message { + return m.CloneVT() +} + func (m *Account) CloneVT() *Account { if m == nil { return (*Account)(nil) @@ -415,7 +454,7 @@ func (m *Account) CloneVT() *Account { r.Metadata = tmpContainer } if rhs := m.Volumes; rhs != nil { - tmpContainer := make(map[string]*VolumesWithBalance, len(rhs)) + tmpContainer := make([]*AccountVolume, len(rhs)) for k, v := range rhs { tmpContainer[k] = v.CloneVT() } @@ -1802,6 +1841,7 @@ func (m *TouchedVolume) CloneVT() *TouchedVolume { r := new(TouchedVolume) r.Account = m.Account r.Asset = m.Asset + r.Color = m.Color if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -4142,6 +4182,7 @@ func (m *AggregatedVolume) CloneVT() *AggregatedVolume { r.Asset = m.Asset r.Input = m.Input.CloneVT() r.Output = m.Output.CloneVT() + r.Color = m.Color if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -4839,6 +4880,9 @@ func (this *Posting) EqualVT(that *Posting) bool { if this.Asset != that.Asset { return false } + if this.Color != that.Color { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -5020,16 +5064,13 @@ func (this *VolumesByAssets) EqualVT(that *VolumesByAssets) bool { return false } for i, vx := range this.Volumes { - vy, ok := that.Volumes[i] - if !ok { - return false - } + vy := that.Volumes[i] if p, q := vx, vy; p != q { if p == nil { - p = &Volumes{} + p = &VolumeEntry{} } if q == nil { - q = &Volumes{} + q = &VolumeEntry{} } if !p.EqualVT(q) { return false @@ -5046,6 +5087,31 @@ func (this *VolumesByAssets) EqualMessageVT(thatMsg proto.Message) bool { } return this.EqualVT(that) } +func (this *VolumeEntry) EqualVT(that *VolumeEntry) bool { + if this == that { + return true + } else if this == nil || that == nil { + return false + } + if this.Asset != that.Asset { + return false + } + if this.Color != that.Color { + return false + } + if !this.Volumes.EqualVT(that.Volumes) { + return false + } + return string(this.unknownFields) == string(that.unknownFields) +} + +func (this *VolumeEntry) EqualMessageVT(thatMsg proto.Message) bool { + that, ok := thatMsg.(*VolumeEntry) + if !ok { + return false + } + return this.EqualVT(that) +} func (this *PostCommitVolumes) EqualVT(that *PostCommitVolumes) bool { if this == that { return true @@ -5082,6 +5148,31 @@ func (this *PostCommitVolumes) EqualMessageVT(thatMsg proto.Message) bool { } return this.EqualVT(that) } +func (this *AccountVolume) EqualVT(that *AccountVolume) bool { + if this == that { + return true + } else if this == nil || that == nil { + return false + } + if this.Asset != that.Asset { + return false + } + if this.Color != that.Color { + return false + } + if !this.Volumes.EqualVT(that.Volumes) { + return false + } + return string(this.unknownFields) == string(that.unknownFields) +} + +func (this *AccountVolume) EqualMessageVT(thatMsg proto.Message) bool { + that, ok := thatMsg.(*AccountVolume) + if !ok { + return false + } + return this.EqualVT(that) +} func (this *Account) EqualVT(that *Account) bool { if this == that { return true @@ -5124,16 +5215,13 @@ func (this *Account) EqualVT(that *Account) bool { return false } for i, vx := range this.Volumes { - vy, ok := that.Volumes[i] - if !ok { - return false - } + vy := that.Volumes[i] if p, q := vx, vy; p != q { if p == nil { - p = &VolumesWithBalance{} + p = &AccountVolume{} } if q == nil { - q = &VolumesWithBalance{} + q = &AccountVolume{} } if !p.EqualVT(q) { return false @@ -7469,6 +7557,9 @@ func (this *TouchedVolume) EqualVT(that *TouchedVolume) bool { if this.Asset != that.Asset { return false } + if this.Color != that.Color { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -11487,6 +11578,9 @@ func (this *AggregatedVolume) EqualVT(that *AggregatedVolume) bool { if !this.Output.EqualVT(that.Output) { return false } + if this.Color != that.Color { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -12485,6 +12579,13 @@ func (m *Posting) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.Color) > 0 { + i -= len(m.Color) + copy(dAtA[i:], m.Color) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Color))) + i-- + dAtA[i] = 0x2a + } if len(m.Asset) > 0 { i -= len(m.Asset) copy(dAtA[i:], m.Asset) @@ -12859,30 +12960,77 @@ func (m *VolumesByAssets) MarshalToSizedBufferVT(dAtA []byte) (int, error) { copy(dAtA[i:], m.unknownFields) } if len(m.Volumes) > 0 { - for k := range m.Volumes { - v := m.Volumes[k] - baseI := i - size, err := v.MarshalToSizedBufferVT(dAtA[:i]) + for iNdEx := len(m.Volumes) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Volumes[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) if err != nil { return 0, err } i -= size i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) i-- - dAtA[i] = 0x12 - i -= len(k) - copy(dAtA[i:], k) - i = protohelpers.EncodeVarint(dAtA, i, uint64(len(k))) - i-- - dAtA[i] = 0xa - i = protohelpers.EncodeVarint(dAtA, i, uint64(baseI-i)) - i-- dAtA[i] = 0xa } } return len(dAtA) - i, nil } +func (m *VolumeEntry) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *VolumeEntry) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *VolumeEntry) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Volumes != nil { + size, err := m.Volumes.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } + if len(m.Color) > 0 { + i -= len(m.Color) + copy(dAtA[i:], m.Color) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Color))) + i-- + dAtA[i] = 0x12 + } + if len(m.Asset) > 0 { + i -= len(m.Asset) + copy(dAtA[i:], m.Asset) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Asset))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + func (m *PostCommitVolumes) MarshalVT() (dAtA []byte, err error) { if m == nil { return nil, nil @@ -12938,6 +13086,63 @@ func (m *PostCommitVolumes) MarshalToSizedBufferVT(dAtA []byte) (int, error) { return len(dAtA) - i, nil } +func (m *AccountVolume) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AccountVolume) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AccountVolume) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Volumes != nil { + size, err := m.Volumes.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } + if len(m.Color) > 0 { + i -= len(m.Color) + copy(dAtA[i:], m.Color) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Color))) + i-- + dAtA[i] = 0x12 + } + if len(m.Asset) > 0 { + i -= len(m.Asset) + copy(dAtA[i:], m.Asset) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Asset))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + func (m *Account) MarshalVT() (dAtA []byte, err error) { if m == nil { return nil, nil @@ -12969,24 +13174,14 @@ func (m *Account) MarshalToSizedBufferVT(dAtA []byte) (int, error) { copy(dAtA[i:], m.unknownFields) } if len(m.Volumes) > 0 { - for k := range m.Volumes { - v := m.Volumes[k] - baseI := i - size, err := v.MarshalToSizedBufferVT(dAtA[:i]) + for iNdEx := len(m.Volumes) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Volumes[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) if err != nil { return 0, err } i -= size i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) i-- - dAtA[i] = 0x12 - i -= len(k) - copy(dAtA[i:], k) - i = protohelpers.EncodeVarint(dAtA, i, uint64(len(k))) - i-- - dAtA[i] = 0xa - i = protohelpers.EncodeVarint(dAtA, i, uint64(baseI-i)) - i-- dAtA[i] = 0x32 } } @@ -16578,6 +16773,13 @@ func (m *TouchedVolume) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.Color) > 0 { + i -= len(m.Color) + copy(dAtA[i:], m.Color) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Color))) + i-- + dAtA[i] = 0x1a + } if len(m.Asset) > 0 { i -= len(m.Asset) copy(dAtA[i:], m.Asset) @@ -22103,6 +22305,13 @@ func (m *AggregatedVolume) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.Color) > 0 { + i -= len(m.Color) + copy(dAtA[i:], m.Color) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Color))) + i-- + dAtA[i] = 0x22 + } if m.Output != nil { size, err := m.Output.MarshalToSizedBufferVT(dAtA[:i]) if err != nil { @@ -23185,6 +23394,10 @@ func (m *Posting) SizeVT() (n int) { if l > 0 { n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + l = len(m.Color) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -23323,22 +23536,37 @@ func (m *VolumesByAssets) SizeVT() (n int) { var l int _ = l if len(m.Volumes) > 0 { - for k, v := range m.Volumes { - _ = k - _ = v - l = 0 - if v != nil { - l = v.SizeVT() - } - l += 1 + protohelpers.SizeOfVarint(uint64(l)) - mapEntrySize := 1 + len(k) + protohelpers.SizeOfVarint(uint64(len(k))) + l - n += mapEntrySize + 1 + protohelpers.SizeOfVarint(uint64(mapEntrySize)) + for _, e := range m.Volumes { + l = e.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } } n += len(m.unknownFields) return n } +func (m *VolumeEntry) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Asset) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + l = len(m.Color) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + if m.Volumes != nil { + l = m.Volumes.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + n += len(m.unknownFields) + return n +} + func (m *PostCommitVolumes) SizeVT() (n int) { if m == nil { return 0 @@ -23362,6 +23590,28 @@ func (m *PostCommitVolumes) SizeVT() (n int) { return n } +func (m *AccountVolume) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Asset) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + l = len(m.Color) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + if m.Volumes != nil { + l = m.Volumes.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + n += len(m.unknownFields) + return n +} + func (m *Account) SizeVT() (n int) { if m == nil { return 0 @@ -23398,16 +23648,9 @@ func (m *Account) SizeVT() (n int) { n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } if len(m.Volumes) > 0 { - for k, v := range m.Volumes { - _ = k - _ = v - l = 0 - if v != nil { - l = v.SizeVT() - } - l += 1 + protohelpers.SizeOfVarint(uint64(l)) - mapEntrySize := 1 + len(k) + protohelpers.SizeOfVarint(uint64(len(k))) + l - n += mapEntrySize + 1 + protohelpers.SizeOfVarint(uint64(mapEntrySize)) + for _, e := range m.Volumes { + l = e.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } } n += len(m.unknownFields) @@ -24940,6 +25183,10 @@ func (m *TouchedVolume) SizeVT() (n int) { if l > 0 { n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + l = len(m.Color) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -27348,6 +27595,10 @@ func (m *AggregatedVolume) SizeVT() (n int) { l = m.Output.SizeVT() n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + l = len(m.Color) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -28631,259 +28882,291 @@ func (m *Posting) UnmarshalVT(dAtA []byte) error { } m.Asset = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex - default: - iNdEx = preIndex - skippy, err := protohelpers.Skip(dAtA[iNdEx:]) - if err != nil { - return err - } - if (skippy < 0) || (iNdEx+skippy) < 0 { - return protohelpers.ErrInvalidLength - } - if (iNdEx + skippy) > l { - return io.ErrUnexpectedEOF - } - m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) - iNdEx += skippy - } - } - - if iNdEx > l { - return io.ErrUnexpectedEOF - } - return nil -} -func (m *Transaction) UnmarshalVT(dAtA []byte) error { - l := len(dAtA) - iNdEx := 0 - for iNdEx < l { - preIndex := iNdEx - var wire uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - wire |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - fieldNum := int32(wire >> 3) - wireType := int(wire & 0x7) - if wireType == 4 { - return fmt.Errorf("proto: Transaction: wiretype end group for non-group") - } - if fieldNum <= 0 { - return fmt.Errorf("proto: Transaction: illegal tag %d (wire type %d)", fieldNum, wire) - } - switch fieldNum { - case 1: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Postings", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - m.Postings = append(m.Postings, &Posting{}) - if err := m.Postings[len(m.Postings)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - iNdEx = postIndex - case 2: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Metadata", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if m.Metadata == nil { - m.Metadata = make(map[string]*MetadataValue) - } - var mapkey string - var mapvalue *MetadataValue - for iNdEx < postIndex { - entryPreIndex := iNdEx - var wire uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - wire |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - fieldNum := int32(wire >> 3) - if fieldNum == 1 { - var stringLenmapkey uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - stringLenmapkey |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - intStringLenmapkey := int(stringLenmapkey) - if intStringLenmapkey < 0 { - return protohelpers.ErrInvalidLength - } - postStringIndexmapkey := iNdEx + intStringLenmapkey - if postStringIndexmapkey < 0 { - return protohelpers.ErrInvalidLength - } - if postStringIndexmapkey > l { - return io.ErrUnexpectedEOF - } - mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) - iNdEx = postStringIndexmapkey - } else if fieldNum == 2 { - var mapmsglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - mapmsglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if mapmsglen < 0 { - return protohelpers.ErrInvalidLength - } - postmsgIndex := iNdEx + mapmsglen - if postmsgIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postmsgIndex > l { - return io.ErrUnexpectedEOF - } - mapvalue = &MetadataValue{} - if err := mapvalue.UnmarshalVT(dAtA[iNdEx:postmsgIndex]); err != nil { - return err - } - iNdEx = postmsgIndex - } else { - iNdEx = entryPreIndex - skippy, err := protohelpers.Skip(dAtA[iNdEx:]) - if err != nil { - return err - } - if (skippy < 0) || (iNdEx+skippy) < 0 { - return protohelpers.ErrInvalidLength - } - if (iNdEx + skippy) > postIndex { - return io.ErrUnexpectedEOF - } - iNdEx += skippy - } - } - m.Metadata[mapkey] = mapvalue - iNdEx = postIndex - case 3: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if m.Timestamp == nil { - m.Timestamp = &Timestamp{} - } - if err := m.Timestamp.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - iNdEx = postIndex - case 4: + case 5: if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Reference", wireType) + return fmt.Errorf("proto: wrong wireType = %d for field Color", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Color = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := protohelpers.Skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return protohelpers.ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Transaction) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Transaction: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Transaction: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Postings", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Postings = append(m.Postings, &Posting{}) + if err := m.Postings[len(m.Postings)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Metadata", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Metadata == nil { + m.Metadata = make(map[string]*MetadataValue) + } + var mapkey string + var mapvalue *MetadataValue + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return protohelpers.ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return protohelpers.ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var mapmsglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + mapmsglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if mapmsglen < 0 { + return protohelpers.ErrInvalidLength + } + postmsgIndex := iNdEx + mapmsglen + if postmsgIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postmsgIndex > l { + return io.ErrUnexpectedEOF + } + mapvalue = &MetadataValue{} + if err := mapvalue.UnmarshalVT(dAtA[iNdEx:postmsgIndex]); err != nil { + return err + } + iNdEx = postmsgIndex + } else { + iNdEx = entryPreIndex + skippy, err := protohelpers.Skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return protohelpers.ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Metadata[mapkey] = mapvalue + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Timestamp == nil { + m.Timestamp = &Timestamp{} + } + if err := m.Timestamp.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Reference", wireType) } var stringLen uint64 for shift := uint(0); ; shift += 7 { @@ -29657,105 +29940,161 @@ func (m *VolumesByAssets) UnmarshalVT(dAtA []byte) error { if postIndex > l { return io.ErrUnexpectedEOF } - if m.Volumes == nil { - m.Volumes = make(map[string]*Volumes) + m.Volumes = append(m.Volumes, &VolumeEntry{}) + if err := m.Volumes[len(m.Volumes)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err } - var mapkey string - var mapvalue *Volumes - for iNdEx < postIndex { - entryPreIndex := iNdEx - var wire uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - wire |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := protohelpers.Skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return protohelpers.ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *VolumeEntry) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: VolumeEntry: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: VolumeEntry: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Asset", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow } - fieldNum := int32(wire >> 3) - if fieldNum == 1 { - var stringLenmapkey uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - stringLenmapkey |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - intStringLenmapkey := int(stringLenmapkey) - if intStringLenmapkey < 0 { - return protohelpers.ErrInvalidLength - } - postStringIndexmapkey := iNdEx + intStringLenmapkey - if postStringIndexmapkey < 0 { - return protohelpers.ErrInvalidLength - } - if postStringIndexmapkey > l { - return io.ErrUnexpectedEOF - } - mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) - iNdEx = postStringIndexmapkey - } else if fieldNum == 2 { - var mapmsglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - mapmsglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if mapmsglen < 0 { - return protohelpers.ErrInvalidLength - } - postmsgIndex := iNdEx + mapmsglen - if postmsgIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postmsgIndex > l { - return io.ErrUnexpectedEOF - } - mapvalue = &Volumes{} - if err := mapvalue.UnmarshalVT(dAtA[iNdEx:postmsgIndex]); err != nil { - return err - } - iNdEx = postmsgIndex - } else { - iNdEx = entryPreIndex - skippy, err := protohelpers.Skip(dAtA[iNdEx:]) - if err != nil { - return err - } - if (skippy < 0) || (iNdEx+skippy) < 0 { - return protohelpers.ErrInvalidLength - } - if (iNdEx + skippy) > postIndex { - return io.ErrUnexpectedEOF - } - iNdEx += skippy + if iNdEx >= l { + return io.ErrUnexpectedEOF } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Asset = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Color", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Color = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Volumes", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Volumes == nil { + m.Volumes = &Volumes{} + } + if err := m.Volumes.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err } - m.Volumes[mapkey] = mapvalue iNdEx = postIndex default: iNdEx = preIndex @@ -29959,6 +30298,157 @@ func (m *PostCommitVolumes) UnmarshalVT(dAtA []byte) error { } return nil } +func (m *AccountVolume) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AccountVolume: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AccountVolume: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Asset", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Asset = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Color", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Color = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Volumes", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Volumes == nil { + m.Volumes = &VolumesWithBalance{} + } + if err := m.Volumes.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := protohelpers.Skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return protohelpers.ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func (m *Account) UnmarshalVT(dAtA []byte) error { l := len(dAtA) iNdEx := 0 @@ -30178,214 +30668,119 @@ func (m *Account) UnmarshalVT(dAtA []byte) error { if postIndex > l { return io.ErrUnexpectedEOF } - if m.FirstUsage == nil { - m.FirstUsage = &Timestamp{} - } - if err := m.FirstUsage.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - iNdEx = postIndex - case 4: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field InsertionDate", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if m.InsertionDate == nil { - m.InsertionDate = &Timestamp{} - } - if err := m.InsertionDate.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - iNdEx = postIndex - case 5: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field UpdatedAt", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if m.UpdatedAt == nil { - m.UpdatedAt = &Timestamp{} - } - if err := m.UpdatedAt.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + if m.FirstUsage == nil { + m.FirstUsage = &Timestamp{} + } + if err := m.FirstUsage.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field InsertionDate", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.InsertionDate == nil { + m.InsertionDate = &Timestamp{} + } + if err := m.InsertionDate.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UpdatedAt", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.UpdatedAt == nil { + m.UpdatedAt = &Timestamp{} + } + if err := m.UpdatedAt.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Volumes", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Volumes = append(m.Volumes, &AccountVolume{}) + if err := m.Volumes[len(m.Volumes)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { return err } iNdEx = postIndex - case 6: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Volumes", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if m.Volumes == nil { - m.Volumes = make(map[string]*VolumesWithBalance) - } - var mapkey string - var mapvalue *VolumesWithBalance - for iNdEx < postIndex { - entryPreIndex := iNdEx - var wire uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - wire |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - fieldNum := int32(wire >> 3) - if fieldNum == 1 { - var stringLenmapkey uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - stringLenmapkey |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - intStringLenmapkey := int(stringLenmapkey) - if intStringLenmapkey < 0 { - return protohelpers.ErrInvalidLength - } - postStringIndexmapkey := iNdEx + intStringLenmapkey - if postStringIndexmapkey < 0 { - return protohelpers.ErrInvalidLength - } - if postStringIndexmapkey > l { - return io.ErrUnexpectedEOF - } - mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) - iNdEx = postStringIndexmapkey - } else if fieldNum == 2 { - var mapmsglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - mapmsglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if mapmsglen < 0 { - return protohelpers.ErrInvalidLength - } - postmsgIndex := iNdEx + mapmsglen - if postmsgIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postmsgIndex > l { - return io.ErrUnexpectedEOF - } - mapvalue = &VolumesWithBalance{} - if err := mapvalue.UnmarshalVT(dAtA[iNdEx:postmsgIndex]); err != nil { - return err - } - iNdEx = postmsgIndex - } else { - iNdEx = entryPreIndex - skippy, err := protohelpers.Skip(dAtA[iNdEx:]) - if err != nil { - return err - } - if (skippy < 0) || (iNdEx+skippy) < 0 { - return protohelpers.ErrInvalidLength - } - if (iNdEx + skippy) > postIndex { - return io.ErrUnexpectedEOF - } - iNdEx += skippy - } - } - m.Volumes[mapkey] = mapvalue - iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) @@ -38855,6 +39250,38 @@ func (m *TouchedVolume) UnmarshalVT(dAtA []byte) error { } m.Asset = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Color", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Color = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) @@ -51266,6 +51693,38 @@ func (m *AggregatedVolume) UnmarshalVT(dAtA []byte) error { return err } iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Color", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Color = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/internal/proto/commonpb/post_commit_volumes_json_test.go b/internal/proto/commonpb/post_commit_volumes_json_test.go index a562071fe0..e034481aab 100644 --- a/internal/proto/commonpb/post_commit_volumes_json_test.go +++ b/internal/proto/commonpb/post_commit_volumes_json_test.go @@ -7,21 +7,35 @@ import ( "github.com/stretchr/testify/require" ) -// TestPostCommitVolumes_MarshalJSON_Flat guards against the protojson leak -// reported on v3.0.0-alpha.7: the wire used to emit -// `{"volumesByAccount": {"addr": {"volumes": {"asset": ...}}}}` — two proto -// wrappers deep — while the OpenAPI schema documents a flat -// `{"addr": {"asset": ...}}` map. The MarshalJSON shim must flatten. +// volumeEntryJSON is the decoded wire shape of a single post-commit volume +// tuple: `{asset, color, input, output}` — flat, with the color dimension +// carried explicitly (empty string = uncolored bucket). +type volumeEntryJSON struct { + Asset string `json:"asset"` + Color string `json:"color"` + Input string `json:"input"` + Output string `json:"output"` +} + +// TestPostCommitVolumes_MarshalJSON_Flat guards the wire contract chosen for +// the color-of-money model: the wire is a flat +// `{"addr": [{asset, color, input, output}]}` map — one array of (asset, +// color) tuples per account. protojson would otherwise emit the raw proto +// wrappers (`{"volumesByAccount": {"addr": {"volumes": [...]}}}`) two levels +// deep. This replaces the pre-color EN-1465 `{"addr": {"asset": Volumes}}` +// map shape, which can no longer key a bucket uniquely once a color dimension +// exists. func TestPostCommitVolumes_MarshalJSON_Flat(t *testing.T) { t.Parallel() pcv := &PostCommitVolumes{ VolumesByAccount: map[string]*VolumesByAssets{ - "users:alice": {Volumes: map[string]*Volumes{ - "USD/2": {Input: "100", Output: "40"}, + "users:alice": {Volumes: []*VolumeEntry{ + {Asset: "USD/2", Color: "", Volumes: &Volumes{Input: "100", Output: "40"}}, + {Asset: "USD/2", Color: "GOLD", Volumes: &Volumes{Input: "10", Output: "0"}}, }}, - "world": {Volumes: map[string]*Volumes{ - "USD/2": {Input: "0", Output: "100"}, + "world": {Volumes: []*VolumeEntry{ + {Asset: "USD/2", Color: "", Volumes: &Volumes{Input: "0", Output: "100"}}, }}, }, } @@ -29,19 +43,28 @@ func TestPostCommitVolumes_MarshalJSON_Flat(t *testing.T) { data, err := pcv.MarshalJSON() require.NoError(t, err) - var out map[string]map[string]struct { - Input string `json:"input"` - Output string `json:"output"` - } - + var out map[string][]volumeEntryJSON require.NoError(t, json.Unmarshal(data, &out)) require.Len(t, out, 2) - require.Equal(t, "100", out["users:alice"]["USD/2"].Input) - require.Equal(t, "40", out["users:alice"]["USD/2"].Output) - require.Equal(t, "0", out["world"]["USD/2"].Input) - require.Equal(t, "100", out["world"]["USD/2"].Output) - // Confirm the wrapper keys are absent from the wire. + require.Len(t, out["users:alice"], 2) + // Uncolored bucket. + require.Equal(t, "USD/2", out["users:alice"][0].Asset) + require.Equal(t, "", out["users:alice"][0].Color) + require.Equal(t, "100", out["users:alice"][0].Input) + require.Equal(t, "40", out["users:alice"][0].Output) + // Colored bucket, distinct from the uncolored one. + require.Equal(t, "USD/2", out["users:alice"][1].Asset) + require.Equal(t, "GOLD", out["users:alice"][1].Color) + require.Equal(t, "10", out["users:alice"][1].Input) + require.Equal(t, "0", out["users:alice"][1].Output) + + require.Len(t, out["world"], 1) + require.Equal(t, "", out["world"][0].Color) + require.Equal(t, "0", out["world"][0].Input) + require.Equal(t, "100", out["world"][0].Output) + + // Confirm the proto wrapper keys are absent from the wire. require.NotContains(t, string(data), "volumesByAccount") require.NotContains(t, string(data), `"volumes"`) } @@ -66,8 +89,8 @@ func TestPostCommitVolumes_MarshalJSON_AccountNamedVolumesByAccount(t *testing.T pcv := &PostCommitVolumes{ VolumesByAccount: map[string]*VolumesByAssets{ - "volumesByAccount": {Volumes: map[string]*Volumes{ - "USD/2": {Input: "100", Output: "40"}, + "volumesByAccount": {Volumes: []*VolumeEntry{ + {Asset: "USD/2", Color: "", Volumes: &Volumes{Input: "100", Output: "40"}}, }}, }, } @@ -75,13 +98,11 @@ func TestPostCommitVolumes_MarshalJSON_AccountNamedVolumesByAccount(t *testing.T data, err := pcv.MarshalJSON() require.NoError(t, err) - var out map[string]map[string]struct { - Input string `json:"input"` - Output string `json:"output"` - } - + var out map[string][]volumeEntryJSON require.NoError(t, json.Unmarshal(data, &out)) require.Contains(t, out, "volumesByAccount") - require.Equal(t, "100", out["volumesByAccount"]["USD/2"].Input) - require.Equal(t, "40", out["volumesByAccount"]["USD/2"].Output) + require.Len(t, out["volumesByAccount"], 1) + require.Equal(t, "USD/2", out["volumesByAccount"][0].Asset) + require.Equal(t, "100", out["volumesByAccount"][0].Input) + require.Equal(t, "40", out["volumesByAccount"][0].Output) } diff --git a/internal/proto/commonpb/posting.go b/internal/proto/commonpb/posting.go index 206c9490a9..1785faffb8 100644 --- a/internal/proto/commonpb/posting.go +++ b/internal/proto/commonpb/posting.go @@ -4,41 +4,43 @@ import ( "math/big" "github.com/holiman/uint256" -) - -// Postings is a slice of Posting pointers. -type Postings []*Posting - -// Reverse reverses the order of postings and swaps source/destination. -func (p Postings) Reverse() Postings { - postings := make(Postings, len(p)) - copy(postings, p) - - for i := range p { - if postings[i] != nil { - postings[i] = &Posting{ - Source: p[i].GetDestination(), - Destination: p[i].GetSource(), - Amount: p[i].GetAmount(), - Asset: p[i].GetAsset(), - } - } - } - // Reverse the order - for i := range len(p) / 2 { - postings[i], postings[len(postings)-i-1] = postings[len(postings)-i-1], postings[i] - } + "github.com/formancehq/ledger/v3/internal/adapter/json" +) - return postings +// MarshalJSON implements json.Marshaler for Posting. Color is always emitted +// (even when empty) so clients can distinguish the uncolored bucket from an +// older response shape that predates the dimension — same contract as +// VolumeEntry and accountVolumeJSON. +func (x *Posting) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Source string `json:"source"` + Destination string `json:"destination"` + Amount *Uint256 `json:"amount,omitempty"` + Asset string `json:"asset"` + Color string `json:"color"` + }{ + Source: x.GetSource(), + Destination: x.GetDestination(), + Amount: x.GetAmount(), + Asset: x.GetAsset(), + Color: x.GetColor(), + }) } -// NewPosting creates a new Posting from the given parameters. -// Converts the *big.Int amount to *Uint256 via uint256.Int intermediary. +// NewPosting creates a new uncolored Posting. Use NewColoredPosting to set a +// non-empty color. Converts the *big.Int amount to *Uint256 via uint256.Int +// intermediary. func NewPosting(source, destination, asset string, amount *big.Int) *Posting { + return NewColoredPosting(source, destination, asset, "", amount) +} + +// NewColoredPosting creates a new Posting with an explicit color. Color is +// the empty string for the uncolored bucket. +func NewColoredPosting(source, destination, asset, color string, amount *big.Int) *Posting { var u uint256.Int if overflow := u.SetFromBig(amount); overflow { - panic("commonpb.NewPosting: amount exceeds 256 bits") + panic("commonpb.NewColoredPosting: amount exceeds 256 bits") } return &Posting{ @@ -46,5 +48,6 @@ func NewPosting(source, destination, asset string, amount *big.Int) *Posting { Destination: destination, Amount: NewUint256(&u), Asset: asset, + Color: color, } } diff --git a/internal/proto/commonpb/posting_json_test.go b/internal/proto/commonpb/posting_json_test.go new file mode 100644 index 0000000000..21d9a20d8f --- /dev/null +++ b/internal/proto/commonpb/posting_json_test.go @@ -0,0 +1,35 @@ +package commonpb + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPosting_MarshalJSON_EmitsEmptyColor guards against the regression where +// REST transaction/create/revert responses omit `color` for the uncolored +// bucket because the generated struct tag is `json:"color,omitempty"`. The +// REST layer must surface `color: ""` so clients can distinguish "uncolored" +// from "field absent in an older response shape". +func TestPosting_MarshalJSON_EmitsEmptyColor(t *testing.T) { + t.Parallel() + + p := NewPosting("world", "users:alice", "USD/2", big.NewInt(100)) + + data, err := json.Marshal(p) + require.NoError(t, err) + require.Contains(t, string(data), `"color":""`, + "uncolored postings must surface color:\"\" rather than dropping the field") +} + +func TestPosting_MarshalJSON_EmitsColor(t *testing.T) { + t.Parallel() + + p := NewColoredPosting("world", "users:alice", "USD/2", "GRANTS", big.NewInt(100)) + + data, err := json.Marshal(p) + require.NoError(t, err) + require.Contains(t, string(data), `"color":"GRANTS"`) +} diff --git a/internal/proto/commonpb/transaction.go b/internal/proto/commonpb/transaction.go index 45aff907ab..63d0c68fcf 100644 --- a/internal/proto/commonpb/transaction.go +++ b/internal/proto/commonpb/transaction.go @@ -115,40 +115,6 @@ func (tx *Transaction) IsReverted() bool { return tx.GetReverted() || tx.GetRevertedAt() != nil } -// Reverse creates a reversed copy of the transaction with swapped source/destination in postings. -func (tx *Transaction) Reverse() *Transaction { - if tx == nil { - return NewTransaction() - } - - postings := Postings(tx.GetPostings()).Reverse() - ret := NewTransaction().WithPostings(postings...) - - // Copy other fields - copy the metadata map reference - ret.Metadata = tx.GetMetadata() - if tx.GetTimestamp() != nil { - ret.Timestamp = tx.GetTimestamp() - } - - ret.Reference = tx.GetReference() - ret.Id = tx.GetId() - - ret.Reverted = tx.GetReverted() - if tx.GetInsertedAt() != nil { - ret.InsertedAt = tx.GetInsertedAt() - } - - if tx.GetUpdatedAt() != nil { - ret.UpdatedAt = tx.GetUpdatedAt() - } - - if tx.GetRevertedAt() != nil { - ret.RevertedAt = tx.GetRevertedAt() - } - - return ret -} - // InvolvedDestinations returns a map of destination accounts to their assets. func (tx *Transaction) InvolvedDestinations() map[string][]string { ret := make(map[string][]string) diff --git a/internal/proto/commonpb/volumes.go b/internal/proto/commonpb/volumes.go index efebfc8235..34650b7a54 100644 --- a/internal/proto/commonpb/volumes.go +++ b/internal/proto/commonpb/volumes.go @@ -4,6 +4,7 @@ import ( "database/sql/driver" "fmt" "math/big" + "sort" "strings" "github.com/invopop/jsonschema" @@ -44,31 +45,6 @@ func (*Volumes) JSONSchemaExtend(schema *jsonschema.Schema) { schema.Properties.Set("balance", inputProperty) } -// Copy creates a deep copy of Volumes. -func (v *Volumes) Copy() *Volumes { - if v == nil { - return &Volumes{} - } - - return &Volumes{ - Input: v.GetInput(), - Output: v.GetOutput(), - } -} - -// NewEmptyVolumes creates new empty volumes. -func NewEmptyVolumes() *Volumes { - return NewVolumesInt64(0, 0) -} - -// NewVolumesInt64 creates new volumes from int64 values. -func NewVolumesInt64(input, output int64) *Volumes { - return &Volumes{ - Input: big.NewInt(input).String(), - Output: big.NewInt(output).String(), - } -} - // Balance calculates the balance (input - output). func (v *Volumes) Balance() *big.Int { if v == nil { @@ -114,198 +90,70 @@ func (v *Volumes) MarshalJSON() ([]byte, error) { }) } -// BalancesByAssets is a type alias for map[string]*big.Int. -type BalancesByAssets = map[string]*big.Int - -// BalancesByAssetsByAccounts is a type alias for map[string]BalancesByAssets. -type BalancesByAssetsByAccounts = map[string]BalancesByAssets - -// Balances calculates balances from VolumesByAssets. -func (v *VolumesByAssets) Balances() BalancesByAssets { - if v == nil || v.Volumes == nil { - return BalancesByAssets{} - } - - balances := BalancesByAssets{} - - for asset, vol := range v.GetVolumes() { - if vol != nil { - input, _ := new(big.Int).SetString(vol.GetInput(), 10) - output, _ := new(big.Int).SetString(vol.GetOutput(), 10) - - if input == nil { - input = big.NewInt(0) - } - - if output == nil { - output = big.NewInt(0) - } - - balances[asset] = new(big.Int).Sub(input, output) - } - } - - return balances +// AssetColored is implemented by every volume-bearing message keyed by an +// (asset, color) tuple. It lets a single comparator order any of them. +type AssetColored interface { + GetAsset() string + GetColor() string } -// Copy creates a deep copy of VolumesByAssets. -func (v *VolumesByAssets) Copy() *VolumesByAssets { - if v == nil { - return &VolumesByAssets{Volumes: make(map[string]*Volumes)} - } - - ret := &VolumesByAssets{ - Volumes: make(map[string]*Volumes), - } - for key, volumes := range v.GetVolumes() { - ret.Volumes[key] = volumes.Copy() +// LessByAssetColor reports whether a sorts before b by (asset, color) +// ascending. It is the single comparator shared by every deterministic +// volume ordering (VolumeEntry, AccountVolume, AggregatedVolume) so the sort +// key can never drift between call sites. +func LessByAssetColor[T AssetColored](a, b T) bool { + if x, y := a.GetAsset(), b.GetAsset(); x != y { + return x < y } - return ret + return a.GetColor() < b.GetColor() } -// AddInput adds an input volume to the specified account and asset. -func (a *PostCommitVolumes) AddInput(account, asset string, input *big.Int) { - if a == nil { - return - } - - if a.VolumesByAccount == nil { - a.VolumesByAccount = make(map[string]*VolumesByAssets) - } - - if _, ok := a.GetVolumesByAccount()[account]; !ok { - a.VolumesByAccount[account] = &VolumesByAssets{ - Volumes: make(map[string]*Volumes), - } - } - - if _, ok := a.GetVolumesByAccount()[account].GetVolumes()[asset]; !ok { - a.VolumesByAccount[account].Volumes[asset] = NewEmptyVolumes() - } - - currentInput, _ := new(big.Int).SetString(a.GetVolumesByAccount()[account].GetVolumes()[asset].GetInput(), 10) - if currentInput == nil { - currentInput = big.NewInt(0) - } - - a.VolumesByAccount[account].Volumes[asset].Input = currentInput.Add(currentInput, input).String() -} - -// AddOutput adds an output volume to the specified account and asset. -func (a *PostCommitVolumes) AddOutput(account, asset string, output *big.Int) { - if a == nil { +// SortVolumes orders the inner volumes list by (asset, color) ascending. +// Stable order is required so JSON / proto output is deterministic across +// reads and so snapshot tests don't flap. +func (v *VolumesByAssets) SortVolumes() { + if v == nil { return } - - if a.VolumesByAccount == nil { - a.VolumesByAccount = make(map[string]*VolumesByAssets) - } - - if _, ok := a.GetVolumesByAccount()[account]; !ok { - a.VolumesByAccount[account] = &VolumesByAssets{ - Volumes: make(map[string]*Volumes), - } - } - - if _, ok := a.GetVolumesByAccount()[account].GetVolumes()[asset]; !ok { - a.VolumesByAccount[account].Volumes[asset] = NewEmptyVolumes() - } - - currentOutput, _ := new(big.Int).SetString(a.GetVolumesByAccount()[account].GetVolumes()[asset].GetOutput(), 10) - if currentOutput == nil { - currentOutput = big.NewInt(0) - } - - a.VolumesByAccount[account].Volumes[asset].Output = currentOutput.Add(currentOutput, output).String() + sort.Slice(v.GetVolumes(), func(i, j int) bool { + return LessByAssetColor(v.GetVolumes()[i], v.GetVolumes()[j]) + }) } -// Copy creates a deep copy of PostCommitVolumes. -func (a *PostCommitVolumes) Copy() *PostCommitVolumes { - if a == nil || len(a.GetVolumesByAccount()) == 0 { - return &PostCommitVolumes{VolumesByAccount: make(map[string]*VolumesByAssets)} - } - - ret := &PostCommitVolumes{ - VolumesByAccount: make(map[string]*VolumesByAssets), - } - for key, volumes := range a.GetVolumesByAccount() { - ret.VolumesByAccount[key] = volumes.Copy() +// FindVolume returns the *Volumes for a given (asset, color) tuple, or nil +// when no entry matches. Color "" is the uncolored bucket. +// +// VolumesByAssets is a sorted list, so this is an O(n) linear scan. For +// repeated lookups, callers should build their own map. +func (v *VolumesByAssets) FindVolume(asset, color string) *Volumes { + if entry := v.findVolumeEntry(asset, color); entry != nil { + return entry.GetVolumes() } - return ret + return nil } -// SubtractPostings subtracts postings from PostCommitVolumes. -func (a *PostCommitVolumes) SubtractPostings(postings []*Posting) *PostCommitVolumes { - if a == nil || len(a.GetVolumesByAccount()) == 0 { - return &PostCommitVolumes{VolumesByAccount: make(map[string]*VolumesByAssets)} +// findVolumeEntry returns the *VolumeEntry matching (asset, color), or nil. +func (v *VolumesByAssets) findVolumeEntry(asset, color string) *VolumeEntry { + if v == nil { + return nil } - - ret := a.Copy() - - for _, posting := range postings { - if posting == nil { - continue + for _, entry := range v.GetVolumes() { + if entry.GetAsset() == asset && entry.GetColor() == color { + return entry } - - ret.AddOutput(posting.GetSource(), posting.GetAsset(), big.NewInt(0).Neg(posting.GetAmount().ToBigInt())) - ret.AddInput(posting.GetDestination(), posting.GetAsset(), big.NewInt(0).Neg(posting.GetAmount().ToBigInt())) } - return ret + return nil } -// Merge merges volumes into PostCommitVolumes. -func (a *PostCommitVolumes) Merge(volumes *PostCommitVolumes) *PostCommitVolumes { +// SortVolumes sorts every per-account volume list deterministically. +func (a *PostCommitVolumes) SortVolumes() { if a == nil { - a = &PostCommitVolumes{VolumesByAccount: make(map[string]*VolumesByAssets)} + return } - - if volumes == nil || volumes.VolumesByAccount == nil { - return a + for _, vba := range a.GetVolumesByAccount() { + vba.SortVolumes() } - - for account, volumesByAssets := range volumes.GetVolumesByAccount() { - if _, ok := a.GetVolumesByAccount()[account]; !ok { - a.VolumesByAccount[account] = &VolumesByAssets{ - Volumes: make(map[string]*Volumes), - } - } - - for asset, vol := range volumesByAssets.GetVolumes() { - if _, ok := a.GetVolumesByAccount()[account].GetVolumes()[asset]; !ok { - a.VolumesByAccount[account].Volumes[asset] = NewEmptyVolumes() - } - - currentInput, _ := new(big.Int).SetString(a.GetVolumesByAccount()[account].GetVolumes()[asset].GetInput(), 10) - currentOutput, _ := new(big.Int).SetString(a.GetVolumesByAccount()[account].GetVolumes()[asset].GetOutput(), 10) - volInput, _ := new(big.Int).SetString(vol.GetInput(), 10) - volOutput, _ := new(big.Int).SetString(vol.GetOutput(), 10) - - if currentInput == nil { - currentInput = big.NewInt(0) - } - - if currentOutput == nil { - currentOutput = big.NewInt(0) - } - - if volInput == nil { - volInput = big.NewInt(0) - } - - if volOutput == nil { - volOutput = big.NewInt(0) - } - - a.VolumesByAccount[account].Volumes[asset].Input = currentInput.Add(currentInput, volInput).String() - a.VolumesByAccount[account].Volumes[asset].Output = currentOutput.Add(currentOutput, volOutput).String() - } - } - - return a } - -// Balances is a type alias for map[string]map[string]*big.Int. -type Balances = map[string]map[string]*big.Int diff --git a/internal/proto/servicepb/bucket.pb.go b/internal/proto/servicepb/bucket.pb.go index 1223086885..2945f9e9e6 100644 --- a/internal/proto/servicepb/bucket.pb.go +++ b/internal/proto/servicepb/bucket.pb.go @@ -396,9 +396,13 @@ type GetAccountRequest struct { Ledger string `protobuf:"bytes,1,opt,name=ledger,proto3" json:"ledger,omitempty"` Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` // checkpoint_id, when non-zero, reads from a query checkpoint instead of the live store - CheckpointId uint64 `protobuf:"fixed64,3,opt,name=checkpoint_id,json=checkpointId,proto3" json:"checkpoint_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CheckpointId uint64 `protobuf:"fixed64,3,opt,name=checkpoint_id,json=checkpointId,proto3" json:"checkpoint_id,omitempty"` + // When true, colored buckets are summed into a single entry per asset + // with color = "" in the returned Account.volumes list. By default each + // (asset, color) tuple gets its own entry. + CollapseColors bool `protobuf:"varint,4,opt,name=collapse_colors,json=collapseColors,proto3" json:"collapse_colors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetAccountRequest) Reset() { @@ -452,6 +456,13 @@ func (x *GetAccountRequest) GetCheckpointId() uint64 { return 0 } +func (x *GetAccountRequest) GetCollapseColors() bool { + if x != nil { + return x.CollapseColors + } + return false +} + type GetTransactionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Ledger string `protobuf:"bytes,1,opt,name=ledger,proto3" json:"ledger,omitempty"` @@ -6724,14 +6735,18 @@ func (x *AnalyzeTransactionsResponse) GetTotalReverted() uint64 { } type FlowPattern struct { - state protoimpl.MessageState `protogen:"open.v1"` - Signature string `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` // e.g. "users:{id}:main -> bank:fees [USD]" - Structure PostingStructure `protobuf:"varint,2,opt,name=structure,proto3,enum=ledger.PostingStructure" json:"structure,omitempty"` - TransactionCount uint64 `protobuf:"fixed64,3,opt,name=transaction_count,json=transactionCount,proto3" json:"transaction_count,omitempty"` - Postings []*NormalizedPosting `protobuf:"bytes,4,rep,name=postings,proto3" json:"postings,omitempty"` - Temporal *TemporalStats `protobuf:"bytes,5,opt,name=temporal,proto3" json:"temporal,omitempty"` - VolumeStats []*AssetVolumeStats `protobuf:"bytes,6,rep,name=volume_stats,json=volumeStats,proto3" json:"volume_stats,omitempty"` - MetadataKeys []string `protobuf:"bytes,7,rep,name=metadata_keys,json=metadataKeys,proto3" json:"metadata_keys,omitempty"` // Distinct metadata keys observed + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable canonical signature, e.g. "users:{id}:main->bank:fees[USD]" + // (legacy form, no spaces; multi-posting flows join fragments with ";"). + // Colored postings append the color inside the brackets: "...[USD/RED]". + // The uncolored bucket keeps the bare "[USD]" form, byte-identical to pre-color. + Signature string `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` + Structure PostingStructure `protobuf:"varint,2,opt,name=structure,proto3,enum=ledger.PostingStructure" json:"structure,omitempty"` + TransactionCount uint64 `protobuf:"fixed64,3,opt,name=transaction_count,json=transactionCount,proto3" json:"transaction_count,omitempty"` + Postings []*NormalizedPosting `protobuf:"bytes,4,rep,name=postings,proto3" json:"postings,omitempty"` + Temporal *TemporalStats `protobuf:"bytes,5,opt,name=temporal,proto3" json:"temporal,omitempty"` + VolumeStats []*AssetVolumeStats `protobuf:"bytes,6,rep,name=volume_stats,json=volumeStats,proto3" json:"volume_stats,omitempty"` + MetadataKeys []string `protobuf:"bytes,7,rep,name=metadata_keys,json=metadataKeys,proto3" json:"metadata_keys,omitempty"` // Distinct metadata keys observed unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -6820,8 +6835,13 @@ type NormalizedPosting struct { SourcePattern string `protobuf:"bytes,1,opt,name=source_pattern,json=sourcePattern,proto3" json:"source_pattern,omitempty"` // e.g. "users:{id}:main" DestinationPattern string `protobuf:"bytes,2,opt,name=destination_pattern,json=destinationPattern,proto3" json:"destination_pattern,omitempty"` // e.g. "bank:fees" Asset string `protobuf:"bytes,3,opt,name=asset,proto3" json:"asset,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // color discriminates postings on the same (source,destination,asset) but + // different color buckets in the FSM. Two flows that share addresses and + // asset but differ in color must produce distinct flow signatures so the + // analysis endpoint does not silently merge segregated buckets. + Color string `protobuf:"bytes,4,opt,name=color,proto3" json:"color,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NormalizedPosting) Reset() { @@ -6875,6 +6895,13 @@ func (x *NormalizedPosting) GetAsset() string { return "" } +func (x *NormalizedPosting) GetColor() string { + if x != nil { + return x.Color + } + return "" +} + type TemporalStats struct { state protoimpl.MessageState `protogen:"open.v1"` FirstSeen *commonpb.Timestamp `protobuf:"bytes,1,opt,name=first_seen,json=firstSeen,proto3" json:"first_seen,omitempty"` @@ -8065,9 +8092,13 @@ type AggregateVolumesRequest struct { // to the first matching prefix. Accounts not matching any prefix are excluded. GroupByPrefixes []string `protobuf:"bytes,5,rep,name=group_by_prefixes,json=groupByPrefixes,proto3" json:"group_by_prefixes,omitempty"` // checkpoint_id, when non-zero, reads from a query checkpoint instead of the live store - CheckpointId uint64 `protobuf:"fixed64,6,opt,name=checkpoint_id,json=checkpointId,proto3" json:"checkpoint_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CheckpointId uint64 `protobuf:"fixed64,6,opt,name=checkpoint_id,json=checkpointId,proto3" json:"checkpoint_id,omitempty"` + // When true, amounts in colored buckets are summed into the uncolored bucket + // ("" color). Result entries are produced with color = "". By default each + // (asset, color) bucket yields its own AggregatedVolume entry. + CollapseColors bool `protobuf:"varint,7,opt,name=collapse_colors,json=collapseColors,proto3" json:"collapse_colors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AggregateVolumesRequest) Reset() { @@ -8142,6 +8173,13 @@ func (x *AggregateVolumesRequest) GetCheckpointId() uint64 { return 0 } +func (x *AggregateVolumesRequest) GetCollapseColors() bool { + if x != nil { + return x.CollapseColors + } + return false +} + // QueryProfile contains execution statistics for a read query. // Returned to clients via gRPC trailing metadata when requested. type QueryProfile struct { @@ -8909,11 +8947,12 @@ var File_bucket_proto protoreflect.FileDescriptor const file_bucket_proto_rawDesc = "" + "\n" + - "\fbucket.proto\x12\x06ledger\x1a\fcommon.proto\x1a\vaudit.proto\x1a\x0fsignature.proto\x1a google/protobuf/descriptor.proto\"j\n" + + "\fbucket.proto\x12\x06ledger\x1a\fcommon.proto\x1a\vaudit.proto\x1a\x0fsignature.proto\x1a google/protobuf/descriptor.proto\"\x93\x01\n" + "\x11GetAccountRequest\x12\x16\n" + "\x06ledger\x18\x01 \x01(\tR\x06ledger\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12#\n" + - "\rcheckpoint_id\x18\x03 \x01(\x06R\fcheckpointId\"{\n" + + "\rcheckpoint_id\x18\x03 \x01(\x06R\fcheckpointId\x12'\n" + + "\x0fcollapse_colors\x18\x04 \x01(\bR\x0ecollapseColors\"{\n" + "\x15GetTransactionRequest\x12\x16\n" + "\x06ledger\x18\x01 \x01(\tR\x06ledger\x12%\n" + "\x0etransaction_id\x18\x02 \x01(\x06R\rtransactionId\x12#\n" + @@ -9362,11 +9401,12 @@ const file_bucket_proto_rawDesc = "" + "\bpostings\x18\x04 \x03(\v2\x19.ledger.NormalizedPostingR\bpostings\x121\n" + "\btemporal\x18\x05 \x01(\v2\x15.ledger.TemporalStatsR\btemporal\x12;\n" + "\fvolume_stats\x18\x06 \x03(\v2\x18.ledger.AssetVolumeStatsR\vvolumeStats\x12#\n" + - "\rmetadata_keys\x18\a \x03(\tR\fmetadataKeys\"\x81\x01\n" + + "\rmetadata_keys\x18\a \x03(\tR\fmetadataKeys\"\x97\x01\n" + "\x11NormalizedPosting\x12%\n" + "\x0esource_pattern\x18\x01 \x01(\tR\rsourcePattern\x12/\n" + "\x13destination_pattern\x18\x02 \x01(\tR\x12destinationPattern\x12\x14\n" + - "\x05asset\x18\x03 \x01(\tR\x05asset\"\xd6\x01\n" + + "\x05asset\x18\x03 \x01(\tR\x05asset\x12\x14\n" + + "\x05color\x18\x04 \x01(\tR\x05color\"\xd6\x01\n" + "\rTemporalStats\x120\n" + "\n" + "first_seen\x18\x01 \x01(\v2\x11.common.TimestampR\tfirstSeen\x12.\n" + @@ -9452,14 +9492,15 @@ const file_bucket_proto_rawDesc = "" + "\fSCOPE_LEDGER\x10\x02\"T\n" + "\x15GetLedgerStatsRequest\x12\x16\n" + "\x06ledger\x18\x01 \x01(\tR\x06ledger\x12#\n" + - "\rcheckpoint_id\x18\x02 \x01(\x06R\fcheckpointId\"\x85\x02\n" + + "\rcheckpoint_id\x18\x02 \x01(\x06R\fcheckpointId\"\xae\x02\n" + "\x17AggregateVolumesRequest\x12\x16\n" + "\x06ledger\x18\x01 \x01(\tR\x06ledger\x12+\n" + "\x06filter\x18\x02 \x01(\v2\x13.common.QueryFilterR\x06filter\x12(\n" + "\x10min_log_sequence\x18\x03 \x01(\x06R\x0eminLogSequence\x12*\n" + "\x11use_max_precision\x18\x04 \x01(\bR\x0fuseMaxPrecision\x12*\n" + "\x11group_by_prefixes\x18\x05 \x03(\tR\x0fgroupByPrefixes\x12#\n" + - "\rcheckpoint_id\x18\x06 \x01(\x06R\fcheckpointId\"\xde\x02\n" + + "\rcheckpoint_id\x18\x06 \x01(\x06R\fcheckpointId\x12'\n" + + "\x0fcollapse_colors\x18\a \x01(\bR\x0ecollapseColors\"\xde\x02\n" + "\fQueryProfile\x12*\n" + "\x11index_duration_us\x18\x01 \x01(\x03R\x0findexDurationUs\x124\n" + "\x16enrichment_duration_us\x18\x02 \x01(\x03R\x14enrichmentDurationUs\x12'\n" + diff --git a/internal/proto/servicepb/bucket_reader.pb.go b/internal/proto/servicepb/bucket_reader.pb.go index be608bebf2..72f4df5889 100644 --- a/internal/proto/servicepb/bucket_reader.pb.go +++ b/internal/proto/servicepb/bucket_reader.pb.go @@ -15,6 +15,7 @@ type GetAccountRequestReader interface { GetLedger() string GetAddress() string GetCheckpointId() uint64 + GetCollapseColors() bool Mutate() *GetAccountRequest } @@ -32,6 +33,10 @@ func (r *getAccountRequestReadonly) GetCheckpointId() uint64 { return (*GetAccountRequest)(r).GetCheckpointId() } +func (r *getAccountRequestReadonly) GetCollapseColors() bool { + return (*GetAccountRequest)(r).GetCollapseColors() +} + func (r *getAccountRequestReadonly) Mutate() *GetAccountRequest { return (*GetAccountRequest)(r).CloneVT() } @@ -7729,6 +7734,7 @@ type NormalizedPostingReader interface { GetSourcePattern() string GetDestinationPattern() string GetAsset() string + GetColor() string Mutate() *NormalizedPosting } @@ -7746,6 +7752,10 @@ func (r *normalizedPostingReadonly) GetAsset() string { return (*NormalizedPosting)(r).GetAsset() } +func (r *normalizedPostingReadonly) GetColor() string { + return (*NormalizedPosting)(r).GetColor() +} + func (r *normalizedPostingReadonly) Mutate() *NormalizedPosting { return (*NormalizedPosting)(r).CloneVT() } @@ -9344,6 +9354,7 @@ type AggregateVolumesRequestReader interface { GetUseMaxPrecision() bool GetGroupByPrefixes() []string GetCheckpointId() uint64 + GetCollapseColors() bool Mutate() *AggregateVolumesRequest } @@ -9377,6 +9388,10 @@ func (r *aggregateVolumesRequestReadonly) GetCheckpointId() uint64 { return (*AggregateVolumesRequest)(r).GetCheckpointId() } +func (r *aggregateVolumesRequestReadonly) GetCollapseColors() bool { + return (*AggregateVolumesRequest)(r).GetCollapseColors() +} + func (r *aggregateVolumesRequestReadonly) Mutate() *AggregateVolumesRequest { return (*AggregateVolumesRequest)(r).CloneVT() } diff --git a/internal/proto/servicepb/bucket_vtproto.pb.go b/internal/proto/servicepb/bucket_vtproto.pb.go index 0e30726237..2b8d3373c3 100644 --- a/internal/proto/servicepb/bucket_vtproto.pb.go +++ b/internal/proto/servicepb/bucket_vtproto.pb.go @@ -31,6 +31,7 @@ func (m *GetAccountRequest) CloneVT() *GetAccountRequest { r.Ledger = m.Ledger r.Address = m.Address r.CheckpointId = m.CheckpointId + r.CollapseColors = m.CollapseColors if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -2464,6 +2465,7 @@ func (m *NormalizedPosting) CloneVT() *NormalizedPosting { r.SourcePattern = m.SourcePattern r.DestinationPattern = m.DestinationPattern r.Asset = m.Asset + r.Color = m.Color if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -2899,6 +2901,7 @@ func (m *AggregateVolumesRequest) CloneVT() *AggregateVolumesRequest { r.MinLogSequence = m.MinLogSequence r.UseMaxPrecision = m.UseMaxPrecision r.CheckpointId = m.CheckpointId + r.CollapseColors = m.CollapseColors if rhs := m.GroupByPrefixes; rhs != nil { tmpContainer := make([]string, len(rhs)) copy(tmpContainer, rhs) @@ -3179,6 +3182,9 @@ func (this *GetAccountRequest) EqualVT(that *GetAccountRequest) bool { if this.CheckpointId != that.CheckpointId { return false } + if this.CollapseColors != that.CollapseColors { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -7105,6 +7111,9 @@ func (this *NormalizedPosting) EqualVT(that *NormalizedPosting) bool { if this.Asset != that.Asset { return false } + if this.Color != that.Color { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -7727,6 +7736,9 @@ func (this *AggregateVolumesRequest) EqualVT(that *AggregateVolumesRequest) bool if this.CheckpointId != that.CheckpointId { return false } + if this.CollapseColors != that.CollapseColors { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -8173,6 +8185,16 @@ func (m *GetAccountRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.CollapseColors { + i-- + if m.CollapseColors { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x20 + } if m.CheckpointId != 0 { i -= 8 binary.LittleEndian.PutUint64(dAtA[i:], uint64(m.CheckpointId)) @@ -14325,6 +14347,13 @@ func (m *NormalizedPosting) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.Color) > 0 { + i -= len(m.Color) + copy(dAtA[i:], m.Color) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.Color))) + i-- + dAtA[i] = 0x22 + } if len(m.Asset) > 0 { i -= len(m.Asset) copy(dAtA[i:], m.Asset) @@ -15444,6 +15473,16 @@ func (m *AggregateVolumesRequest) MarshalToSizedBufferVT(dAtA []byte) (int, erro i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.CollapseColors { + i-- + if m.CollapseColors { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x38 + } if m.CheckpointId != 0 { i -= 8 binary.LittleEndian.PutUint64(dAtA[i:], uint64(m.CheckpointId)) @@ -16176,6 +16215,9 @@ func (m *GetAccountRequest) SizeVT() (n int) { if m.CheckpointId != 0 { n += 9 } + if m.CollapseColors { + n += 2 + } n += len(m.unknownFields) return n } @@ -18725,6 +18767,10 @@ func (m *NormalizedPosting) SizeVT() (n int) { if l > 0 { n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + l = len(m.Color) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -19172,6 +19218,9 @@ func (m *AggregateVolumesRequest) SizeVT() (n int) { if m.CheckpointId != 0 { n += 9 } + if m.CollapseColors { + n += 2 + } n += len(m.unknownFields) return n } @@ -19556,6 +19605,26 @@ func (m *GetAccountRequest) UnmarshalVT(dAtA []byte) error { } m.CheckpointId = uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:])) iNdEx += 8 + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field CollapseColors", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.CollapseColors = bool(v != 0) default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) @@ -33476,6 +33545,38 @@ func (m *NormalizedPosting) UnmarshalVT(dAtA []byte) error { } m.Asset = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Color", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Color = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) @@ -36107,6 +36208,26 @@ func (m *AggregateVolumesRequest) UnmarshalVT(dAtA []byte) error { } m.CheckpointId = uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:])) iNdEx += 8 + case 7: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field CollapseColors", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.CollapseColors = bool(v != 0) default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/internal/query/aggregate.go b/internal/query/aggregate.go index 124d1279e4..f3df5dec9a 100644 --- a/internal/query/aggregate.go +++ b/internal/query/aggregate.go @@ -15,14 +15,16 @@ import ( "github.com/formancehq/ledger/v3/internal/storage/readstore" ) -// assetKey identifies an asset by its base name and precision, used as map key -// in the aggregator to avoid string formatting/parsing round-trips. +// assetKey identifies a balance bucket by (base, precision, color). Color is +// always part of the key — different colors are distinct buckets. To collapse +// across colors at the result stage, use AggregateOptions.CollapseColors. type assetKey struct { base string precision uint8 + color string } -// aggregatedVol accumulates input/output volumes for a single asset. +// aggregatedVol accumulates input/output volumes for a single bucket. type aggregatedVol struct { input *uint256.Int output *uint256.Int @@ -32,12 +34,14 @@ type aggregatedVol struct { type volumeAggregator struct { byAsset map[assetKey]*aggregatedVol useMaxPrecision bool + collapseColors bool } -func newVolumeAggregator(useMaxPrecision bool) *volumeAggregator { +func newVolumeAggregator(useMaxPrecision, collapseColors bool) *volumeAggregator { return &volumeAggregator{ byAsset: make(map[assetKey]*aggregatedVol), useMaxPrecision: useMaxPrecision, + collapseColors: collapseColors, } } @@ -49,13 +53,11 @@ func (va *volumeAggregator) accumulate(entry attributes.ComputedEntry[*raftcmdpb return fmt.Errorf("unmarshaling volume key: %w", err) } - va.accumulateAsset(vk.AssetBase, vk.AssetPrecision, entry.Value) - - return nil + return va.accumulateAsset(vk.AssetBase, vk.AssetPrecision, vk.Color, entry.Value) } -func (va *volumeAggregator) accumulateAsset(base string, precision uint8, value *raftcmdpb.VolumePair) { - key := assetKey{base: base, precision: precision} +func (va *volumeAggregator) accumulateAsset(base string, precision uint8, color string, value *raftcmdpb.VolumePair) error { + key := assetKey{base: base, precision: precision, color: color} agg, ok := va.byAsset[key] if !ok { @@ -70,14 +72,20 @@ func (va *volumeAggregator) accumulateAsset(base string, precision uint8, value var tmp uint256.Int if value.GetInput() != nil { value.GetInput().IntoUint256(&tmp) - agg.input.Add(agg.input, &tmp) + if _, overflow := agg.input.AddOverflow(agg.input, &tmp); overflow { + return &ErrAggregateOverflow{Stage: "accumulate", Side: "input"} + } } if value.GetOutput() != nil { value.GetOutput().IntoUint256(&tmp) - agg.output.Add(agg.output, &tmp) + if _, overflow := agg.output.AddOverflow(agg.output, &tmp); overflow { + return &ErrAggregateOverflow{Stage: "accumulate", Side: "output"} + } } } + + return nil } // pow10 returns 10^exp as a uint256.Int. @@ -92,30 +100,73 @@ func pow10(exp uint8) *uint256.Int { return result } -func (va *volumeAggregator) result() *commonpb.AggregateResult { +func (va *volumeAggregator) result() (*commonpb.AggregateResult, error) { if va.useMaxPrecision { return va.resultWithMaxPrecision() } - volumes := make([]*commonpb.AggregatedVolume, 0, len(va.byAsset)) - for key, agg := range va.byAsset { + buckets := va.byAsset + if va.collapseColors { + var err error + buckets, err = collapseColorBuckets(buckets) + if err != nil { + return nil, err + } + } + + volumes := make([]*commonpb.AggregatedVolume, 0, len(buckets)) + for key, agg := range buckets { volumes = append(volumes, &commonpb.AggregatedVolume{ Asset: domain.FormatAsset(key.base, key.precision), + Color: key.color, Input: commonpb.NewUint256(agg.input), Output: commonpb.NewUint256(agg.output), }) } + sortAggregatedVolumes(volumes) + + return &commonpb.AggregateResult{Volumes: volumes}, nil +} + +// collapseColorBuckets sums all color buckets of the same (base, precision) +// into the empty-color bucket, returning a new map. Used when the caller +// explicitly requests collapse_colors. Overflow on the cross-color sum +// surfaces ErrAggregateOverflow rather than silently wrapping (the FSM +// already rejects per-bucket overflow on write, #321). +func collapseColorBuckets(in map[assetKey]*aggregatedVol) (map[assetKey]*aggregatedVol, error) { + out := make(map[assetKey]*aggregatedVol, len(in)) + for key, agg := range in { + k := assetKey{base: key.base, precision: key.precision} + merged, ok := out[k] + if !ok { + merged = &aggregatedVol{ + input: new(uint256.Int), + output: new(uint256.Int), + } + out[k] = merged + } + if _, overflow := merged.input.AddOverflow(merged.input, agg.input); overflow { + return nil, &ErrAggregateOverflow{Stage: "collapse-colors", Side: "input"} + } + if _, overflow := merged.output.AddOverflow(merged.output, agg.output); overflow { + return nil, &ErrAggregateOverflow{Stage: "collapse-colors", Side: "output"} + } + } + + return out, nil +} + +func sortAggregatedVolumes(volumes []*commonpb.AggregatedVolume) { sort.Slice(volumes, func(i, j int) bool { - return volumes[i].GetAsset() < volumes[j].GetAsset() + return commonpb.LessByAssetColor(volumes[i], volumes[j]) }) - - return &commonpb.AggregateResult{Volumes: volumes} } // resultWithMaxPrecision merges assets sharing the same base under the highest -// precision observed, rescaling lower-precision amounts. -func (va *volumeAggregator) resultWithMaxPrecision() *commonpb.AggregateResult { +// precision observed, rescaling lower-precision amounts. Color is preserved as +// part of the bucket key (and optionally collapsed afterwards). +func (va *volumeAggregator) resultWithMaxPrecision() (*commonpb.AggregateResult, error) { // First pass: find max precision per asset base. maxPrec := make(map[string]uint8) for key := range va.byAsset { @@ -124,12 +175,12 @@ func (va *volumeAggregator) resultWithMaxPrecision() *commonpb.AggregateResult { } } - // Second pass: rescale and merge under target precision. + // Second pass: rescale and merge under target precision, keeping color. merged := make(map[assetKey]*aggregatedVol) for key, agg := range va.byAsset { target := maxPrec[key.base] - mergedKey := assetKey{base: key.base, precision: target} + mergedKey := assetKey{base: key.base, precision: target, color: key.color} m, ok := merged[mergedKey] if !ok { @@ -141,17 +192,37 @@ func (va *volumeAggregator) resultWithMaxPrecision() *commonpb.AggregateResult { } if key.precision == target { - m.input.Add(m.input, agg.input) - m.output.Add(m.output, agg.output) + if _, overflow := m.input.AddOverflow(m.input, agg.input); overflow { + return nil, &ErrAggregateOverflow{Stage: "max-precision-merge", Side: "input"} + } + if _, overflow := m.output.AddOverflow(m.output, agg.output); overflow { + return nil, &ErrAggregateOverflow{Stage: "max-precision-merge", Side: "output"} + } } else { factor := pow10(target - key.precision) var scaled uint256.Int - scaled.Mul(agg.input, factor) - m.input.Add(m.input, &scaled) + if _, overflow := scaled.MulOverflow(agg.input, factor); overflow { + return nil, &ErrAggregateOverflow{Stage: "max-precision-rescale", Side: "input"} + } + if _, overflow := m.input.AddOverflow(m.input, &scaled); overflow { + return nil, &ErrAggregateOverflow{Stage: "max-precision-rescale", Side: "input"} + } - scaled.Mul(agg.output, factor) - m.output.Add(m.output, &scaled) + if _, overflow := scaled.MulOverflow(agg.output, factor); overflow { + return nil, &ErrAggregateOverflow{Stage: "max-precision-rescale", Side: "output"} + } + if _, overflow := m.output.AddOverflow(m.output, &scaled); overflow { + return nil, &ErrAggregateOverflow{Stage: "max-precision-rescale", Side: "output"} + } + } + } + + if va.collapseColors { + var err error + merged, err = collapseColorBuckets(merged) + if err != nil { + return nil, err } } @@ -159,22 +230,25 @@ func (va *volumeAggregator) resultWithMaxPrecision() *commonpb.AggregateResult { for key, agg := range merged { volumes = append(volumes, &commonpb.AggregatedVolume{ Asset: domain.FormatAsset(key.base, key.precision), + Color: key.color, Input: commonpb.NewUint256(agg.input), Output: commonpb.NewUint256(agg.output), }) } - sort.Slice(volumes, func(i, j int) bool { - return volumes[i].GetAsset() < volumes[j].GetAsset() - }) + sortAggregatedVolumes(volumes) - return &commonpb.AggregateResult{Volumes: volumes} + return &commonpb.AggregateResult{Volumes: volumes}, nil } // AggregateOptions configures volume aggregation behavior. type AggregateOptions struct { UseMaxPrecision bool GroupByPrefixes []string + // CollapseColors, when true, sums all color buckets of the same + // (asset_base, precision) into the empty-color bucket in the result. + // By default each (asset, color) tuple yields its own AggregatedVolume. + CollapseColors bool } // groupedAggregator dispatches volume entries to per-prefix volumeAggregators. @@ -187,7 +261,7 @@ type groupedAggregator struct { func newGroupedAggregator(opts AggregateOptions) *groupedAggregator { aggs := make(map[string]*volumeAggregator, len(opts.GroupByPrefixes)) for _, p := range opts.GroupByPrefixes { - aggs[p] = newVolumeAggregator(opts.UseMaxPrecision) + aggs[p] = newVolumeAggregator(opts.UseMaxPrecision, opts.CollapseColors) } return &groupedAggregator{ @@ -217,21 +291,23 @@ func (ga *groupedAggregator) accumulate(entry attributes.ComputedEntry[*raftcmdp return nil // account doesn't match any prefix, skip } - ga.aggregators[prefix].accumulateAsset(vk.AssetBase, vk.AssetPrecision, entry.Value) - - return nil + return ga.aggregators[prefix].accumulateAsset(vk.AssetBase, vk.AssetPrecision, vk.Color, entry.Value) } -func (ga *groupedAggregator) result() *commonpb.AggregateResult { +func (ga *groupedAggregator) result() (*commonpb.AggregateResult, error) { groups := make([]*commonpb.GroupedAggregateResult, 0, len(ga.prefixes)) for _, p := range ga.prefixes { + res, err := ga.aggregators[p].result() + if err != nil { + return nil, err + } groups = append(groups, &commonpb.GroupedAggregateResult{ Prefix: p, - Volumes: ga.aggregators[p].result().GetVolumes(), + Volumes: res.GetVolumes(), }) } - return &commonpb.AggregateResult{Groups: groups} + return &commonpb.AggregateResult{Groups: groups}, nil } // AggregateVolumes executes a cross-store merge-scan for filtered aggregation: @@ -280,7 +356,7 @@ func AggregateVolumes( } } - return acc.result(), nil + return acc.result() } // AggregateAllVolumes performs unfiltered volume aggregation in a single Pebble @@ -326,13 +402,13 @@ func AggregateAllVolumes( return nil, fmt.Errorf("scanning all volumes: %w", err) } - return acc.result(), nil + return acc.result() } // accumulator is the common interface for flat and grouped aggregation. type accumulator interface { accumulate(entry attributes.ComputedEntry[*raftcmdpb.VolumePair]) error - result() *commonpb.AggregateResult + result() (*commonpb.AggregateResult, error) } func newAccumulator(opts AggregateOptions) accumulator { @@ -340,5 +416,5 @@ func newAccumulator(opts AggregateOptions) accumulator { return newGroupedAggregator(opts) } - return newVolumeAggregator(opts.UseMaxPrecision) + return newVolumeAggregator(opts.UseMaxPrecision, opts.CollapseColors) } diff --git a/internal/query/aggregate_test.go b/internal/query/aggregate_test.go index 19c3150df1..cff53b6fe8 100644 --- a/internal/query/aggregate_test.go +++ b/internal/query/aggregate_test.go @@ -30,12 +30,13 @@ func makeEntry(ledgerName string, account, asset string, input, output uint64) a func TestVolumeAggregator_NoRescaling(t *testing.T) { t.Parallel() - va := newVolumeAggregator(false) + va := newVolumeAggregator(false, false) require.NoError(t, va.accumulate(makeEntry("test", "a", "USD/2", 100, 50))) require.NoError(t, va.accumulate(makeEntry("test", "a", "USD/4", 10000, 5000))) - result := va.result() + result, err := va.result() + require.NoError(t, err) require.Len(t, result.GetVolumes(), 2) require.Equal(t, "USD/2", result.GetVolumes()[0].GetAsset()) require.Equal(t, "USD/4", result.GetVolumes()[1].GetAsset()) @@ -44,14 +45,15 @@ func TestVolumeAggregator_NoRescaling(t *testing.T) { func TestVolumeAggregator_UseMaxPrecision(t *testing.T) { t.Parallel() - va := newVolumeAggregator(true) + va := newVolumeAggregator(true, false) // USD/2: 100 in, 50 out → rescaled to /4: 10000 in, 5000 out require.NoError(t, va.accumulate(makeEntry("test", "a", "USD/2", 100, 50))) // USD/4: 10000 in, 5000 out → stays as is require.NoError(t, va.accumulate(makeEntry("test", "b", "USD/4", 10000, 5000))) - result := va.result() + result, err := va.result() + require.NoError(t, err) require.Len(t, result.GetVolumes(), 1) require.Equal(t, "USD/4", result.GetVolumes()[0].GetAsset()) @@ -66,12 +68,13 @@ func TestVolumeAggregator_UseMaxPrecision(t *testing.T) { func TestVolumeAggregator_UseMaxPrecision_SamePrecision(t *testing.T) { t.Parallel() - va := newVolumeAggregator(true) + va := newVolumeAggregator(true, false) require.NoError(t, va.accumulate(makeEntry("test", "a", "EUR/2", 200, 100))) require.NoError(t, va.accumulate(makeEntry("test", "b", "EUR/2", 300, 150))) - result := va.result() + result, err := va.result() + require.NoError(t, err) require.Len(t, result.GetVolumes(), 1) require.Equal(t, "EUR/2", result.GetVolumes()[0].GetAsset()) @@ -83,13 +86,14 @@ func TestVolumeAggregator_UseMaxPrecision_SamePrecision(t *testing.T) { func TestVolumeAggregator_UseMaxPrecision_MixedAssets(t *testing.T) { t.Parallel() - va := newVolumeAggregator(true) + va := newVolumeAggregator(true, false) require.NoError(t, va.accumulate(makeEntry("test", "a", "USD/2", 100, 0))) require.NoError(t, va.accumulate(makeEntry("test", "a", "USD/4", 10000, 0))) require.NoError(t, va.accumulate(makeEntry("test", "a", "EUR/2", 200, 0))) - result := va.result() + result, err := va.result() + require.NoError(t, err) require.Len(t, result.GetVolumes(), 2) // Sorted: EUR/2, USD/4 require.Equal(t, "EUR/2", result.GetVolumes()[0].GetAsset()) @@ -99,12 +103,13 @@ func TestVolumeAggregator_UseMaxPrecision_MixedAssets(t *testing.T) { func TestVolumeAggregator_UseMaxPrecision_NoPrecision(t *testing.T) { t.Parallel() - va := newVolumeAggregator(true) + va := newVolumeAggregator(true, false) require.NoError(t, va.accumulate(makeEntry("test", "a", "GOLD", 500, 100))) require.NoError(t, va.accumulate(makeEntry("test", "b", "GOLD", 300, 200))) - result := va.result() + result, err := va.result() + require.NoError(t, err) require.Len(t, result.GetVolumes(), 1) require.Equal(t, "GOLD", result.GetVolumes()[0].GetAsset()) @@ -124,7 +129,8 @@ func TestGroupedAggregator_BasicPrefixes(t *testing.T) { require.NoError(t, ga.accumulate(makeEntry("test", "users:bob", "USD/2", 200, 100))) require.NoError(t, ga.accumulate(makeEntry("test", "merchants:shop1", "USD/2", 500, 250))) - result := ga.result() + result, err := ga.result() + require.NoError(t, err) require.Empty(t, result.GetVolumes(), "flat volumes should be empty for grouped result") require.Len(t, result.GetGroups(), 2) @@ -154,7 +160,8 @@ func TestGroupedAggregator_UnmatchedAccountSkipped(t *testing.T) { require.NoError(t, ga.accumulate(makeEntry("test", "users:alice", "USD/2", 100, 50))) require.NoError(t, ga.accumulate(makeEntry("test", "world", "USD/2", 9999, 9999))) // no match - result := ga.result() + result, err := ga.result() + require.NoError(t, err) require.Len(t, result.GetGroups(), 1) var input uint256.Int @@ -173,7 +180,8 @@ func TestGroupedAggregator_WithMaxPrecision(t *testing.T) { require.NoError(t, ga.accumulate(makeEntry("test", "users:alice", "USD/2", 100, 50))) require.NoError(t, ga.accumulate(makeEntry("test", "users:bob", "USD/4", 10000, 5000))) - result := ga.result() + result, err := ga.result() + require.NoError(t, err) require.Len(t, result.GetGroups(), 1) require.Len(t, result.GetGroups()[0].GetVolumes(), 1) require.Equal(t, "USD/4", result.GetGroups()[0].GetVolumes()[0].GetAsset()) @@ -192,7 +200,8 @@ func TestGroupedAggregator_FirstPrefixWins(t *testing.T) { require.NoError(t, ga.accumulate(makeEntry("test", "users:vip1", "USD/2", 100, 50))) - result := ga.result() + result, err := ga.result() + require.NoError(t, err) // "users:vip1" matches "users:" first. var input uint256.Int result.GetGroups()[0].GetVolumes()[0].GetInput().IntoUint256(&input) @@ -208,7 +217,8 @@ func TestNewAccumulator_FlatByDefault(t *testing.T) { acc := newAccumulator(AggregateOptions{}) require.NoError(t, acc.accumulate(makeEntry("test", "a", "USD/2", 100, 50))) - result := acc.result() + result, err := acc.result() + require.NoError(t, err) require.Len(t, result.GetVolumes(), 1) require.Empty(t, result.GetGroups()) } @@ -219,7 +229,8 @@ func TestNewAccumulator_GroupedWhenPrefixes(t *testing.T) { acc := newAccumulator(AggregateOptions{GroupByPrefixes: []string{"a:"}}) require.NoError(t, acc.accumulate(makeEntry("test", "a:1", "USD/2", 100, 50))) - result := acc.result() + result, err := acc.result() + require.NoError(t, err) require.Empty(t, result.GetVolumes()) require.Len(t, result.GetGroups(), 1) } @@ -232,3 +243,117 @@ func TestPow10(t *testing.T) { require.Equal(t, uint256.NewInt(100), pow10(2)) require.Equal(t, uint256.NewInt(1000000), pow10(6)) } + +// makeColoredEntry builds a volume entry with a non-empty color, used to +// verify the aggregator keeps color-segregated buckets distinct. +func makeColoredEntry(ledgerName, account, asset, color string, input, output uint64) attributes.ComputedEntry[*raftcmdpb.VolumePair] { + vk := domain.VolumeKey{ + AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: account}, + Asset: asset, + Color: color, + } + + return attributes.ComputedEntry[*raftcmdpb.VolumePair]{ + CanonicalKey: vk.Bytes(), + Value: &raftcmdpb.VolumePair{ + Input: commonpb.NewUint256(uint256.NewInt(input)), + Output: commonpb.NewUint256(uint256.NewInt(output)), + }, + } +} + +func TestVolumeAggregator_SegregatesColorsByDefault(t *testing.T) { + t.Parallel() + + va := newVolumeAggregator(false, false) + + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "", 100, 50))) + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "GRANTS", 200, 80))) + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "OPS", 30, 10))) + + result, err := va.result() + require.NoError(t, err) + require.Len(t, result.GetVolumes(), 3, + "each (asset, color) bucket must yield its own AggregatedVolume entry by default") + + byColor := map[string]*commonpb.AggregatedVolume{} + for _, v := range result.GetVolumes() { + byColor[v.GetColor()] = v + } + + require.Contains(t, byColor, "") + require.Contains(t, byColor, "GRANTS") + require.Contains(t, byColor, "OPS") + + var got uint256.Int + byColor["GRANTS"].GetInput().IntoUint256(&got) + require.Equal(t, uint256.NewInt(200), &got, "GRANTS bucket must keep its own input") +} + +func TestVolumeAggregator_CollapseColors(t *testing.T) { + t.Parallel() + + va := newVolumeAggregator(false, true) // collapseColors=true + + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "", 100, 50))) + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "GRANTS", 200, 80))) + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "OPS", 30, 10))) + + result, err := va.result() + require.NoError(t, err) + require.Len(t, result.GetVolumes(), 1, "collapse_colors must produce a single per-asset entry") + + vol := result.GetVolumes()[0] + require.Equal(t, "USD/2", vol.GetAsset()) + require.Equal(t, "", vol.GetColor(), "collapsed entries are produced under the empty color") + + var input, output uint256.Int + vol.GetInput().IntoUint256(&input) + vol.GetOutput().IntoUint256(&output) + require.Equal(t, uint256.NewInt(330), &input, "100+200+30") + require.Equal(t, uint256.NewInt(140), &output, "50+80+10") +} + +func TestVolumeAggregator_CollapseColors_WithMaxPrecision(t *testing.T) { + t.Parallel() + + va := newVolumeAggregator(true, true) + + // USD/2 RED: 100 in, 50 out → rescaled to /4: 10000, 5000 + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/2", "RED", 100, 50))) + // USD/4 BLUE: 1, 0 + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/4", "BLUE", 1, 0))) + // USD/4 (uncolored): 2, 1 + require.NoError(t, va.accumulate(makeColoredEntry("test", "a", "USD/4", "", 2, 1))) + + result, err := va.result() + require.NoError(t, err) + require.Len(t, result.GetVolumes(), 1, "collapse_colors + max_precision must collapse all to one") + + vol := result.GetVolumes()[0] + require.Equal(t, "USD/4", vol.GetAsset()) + require.Empty(t, vol.GetColor()) + + var input, output uint256.Int + vol.GetInput().IntoUint256(&input) + vol.GetOutput().IntoUint256(&output) + require.Equal(t, uint256.NewInt(10003), &input, "10000 + 1 + 2") + require.Equal(t, uint256.NewInt(5001), &output, "5000 + 0 + 1") +} + +func TestGroupedAggregator_RespectsColor(t *testing.T) { + t.Parallel() + + ga := newGroupedAggregator(AggregateOptions{ + GroupByPrefixes: []string{"users:"}, + }) + + require.NoError(t, ga.accumulate(makeColoredEntry("test", "users:alice", "USD/2", "RED", 100, 0))) + require.NoError(t, ga.accumulate(makeColoredEntry("test", "users:alice", "USD/2", "BLUE", 50, 0))) + + result, err := ga.result() + require.NoError(t, err) + require.Len(t, result.GetGroups(), 1) + require.Len(t, result.GetGroups()[0].GetVolumes(), 2, + "grouped aggregator must keep colors segregated within a prefix bucket") +} diff --git a/internal/query/compact_account.go b/internal/query/compact_account.go index 81852669ae..deb0cdddce 100644 --- a/internal/query/compact_account.go +++ b/internal/query/compact_account.go @@ -32,12 +32,18 @@ type compactSubIter struct { // Current account state — populated by advance(). addr []byte - assets []string // only for V type + assets []string // only for V type — deduped asset names (color-collapsed) + seenAssets map[string]struct{} metadataKeys []string // only for M type lastCanonical []byte // for dedup valid bool started bool + + // err is set when the iterator encounters a malformed key (a "should not + // happen" branch per CLAUDE.md invariant #7) so the outer Next() can + // surface the violation instead of silently dropping the entry. + err error } // NewCompactAccountIterator creates an iterator that yields CompactAccount @@ -89,6 +95,9 @@ func (si *compactSubIter) advance() bool { si.metadataKeys = si.metadataKeys[:0] si.lastCanonical = si.lastCanonical[:0] si.addr = nil + if si.seenAssets != nil { + clear(si.seenAssets) + } if !si.started { si.started = true @@ -138,8 +147,40 @@ func (si *compactSubIter) advance() bool { switch si.attrType { case dal.SubAttrVolume: - if len(nameBytes) >= 2 { - si.assets = append(si.assets, domain.FormatAsset(string(nameBytes[:len(nameBytes)-1]), nameBytes[len(nameBytes)-1])) + // Volume key layout after the account separator is + // [color]\x00[asset_base][precision_byte] + // We surface deduped asset names (color is collapsed at the + // analysis layer — pattern discovery does not care about + // color), so split on the second 0x00 to skip the color. + colorSep := bytes.IndexByte(nameBytes, dal.CanonicalKeySepVolume) + if colorSep < 0 { + // "Should not happen": every volume key written by the + // FSM carries the color separator (CLAUDE.md invariant + // #7). Surface the violation loudly instead of silently + // skipping the entry — a missing separator means either + // pre-color legacy data or storage corruption. + si.err = fmt.Errorf("compact_account: volume key missing color separator (canonical=%x)", canonical) + si.valid = false + + return false + } + assetBytes := nameBytes[colorSep+1:] + if len(assetBytes) < 2 { + // Same class of invariant: asset_base must be at least + // one byte plus the trailing precision byte. Anything + // shorter is corrupt. + si.err = fmt.Errorf("compact_account: volume key asset section too short (canonical=%x)", canonical) + si.valid = false + + return false + } + asset := domain.FormatAsset(string(assetBytes[:len(assetBytes)-1]), assetBytes[len(assetBytes)-1]) + if si.seenAssets == nil { + si.seenAssets = make(map[string]struct{}) + } + if _, ok := si.seenAssets[asset]; !ok { + si.seenAssets[asset] = struct{}{} + si.assets = append(si.assets, asset) } case dal.SubAttrMetadata: si.metadataKeys = append(si.metadataKeys, string(nameBytes)) @@ -169,6 +210,20 @@ func (it *CompactAccountIterator) Next() (analysis.CompactAccount, error) { it.m.advance() } + // An invariant violation surfaced by advance() must not be silently + // dropped: the upstream key layout is wrong and the consumer needs to + // know rather than silently miss volume entries. + if it.v.err != nil { + it.done = true + + return analysis.CompactAccount{}, it.v.err + } + if it.m.err != nil { + it.done = true + + return analysis.CompactAccount{}, it.m.err + } + if !it.v.valid && !it.m.valid { it.done = true diff --git a/internal/query/errors.go b/internal/query/errors.go new file mode 100644 index 0000000000..869facd953 --- /dev/null +++ b/internal/query/errors.go @@ -0,0 +1,38 @@ +package query + +import ( + "fmt" + + "github.com/formancehq/ledger/v3/internal/domain" +) + +// ErrAggregateOverflow signals that summing colored or precision-rescaled +// buckets in the read-side aggregator exceeded the 2^256 uint256 ceiling. +// The FSM already rejects per-bucket overflow on write (#321); this guards +// the aggregator with the same discipline since collapseColors and +// use_max_precision can sum many buckets together. +// +// This is a query-only outcome — it is produced by the read-side aggregator +// (aggregate.go), never emitted by the FSM apply path — so it lives in the +// query layer, not in internal/domain (which is reserved for FSM-generated +// business outcomes). It still implements domain.Describable so it flows +// through the shared error pipeline to the gRPC/HTTP adapters, and it reuses +// the domain-level wire constant domain.ErrReasonAggregateOverflow (the +// client-facing reason string and its KindForReason classification are the +// shared wire contract and stay in domain). +type ErrAggregateOverflow struct { + Stage string // "accumulate", "collapse-colors", "max-precision-merge" or "max-precision-rescale" + Side string // "input" or "output" +} + +func (e *ErrAggregateOverflow) Error() string { + return fmt.Sprintf("aggregate volume %s overflowed 2^256 during %s", e.Side, e.Stage) +} +func (*ErrAggregateOverflow) Reason() string { return domain.ErrReasonAggregateOverflow } +func (e *ErrAggregateOverflow) Metadata() map[string]string { + return map[string]string{"stage": e.Stage, "side": e.Side} +} + +// Compile-time assertion that ErrAggregateOverflow satisfies domain.Describable +// so it keeps flowing through the shared error edge (gRPC/HTTP mapping). +var _ domain.Describable = (*ErrAggregateOverflow)(nil) diff --git a/internal/query/errors_test.go b/internal/query/errors_test.go new file mode 100644 index 0000000000..4638c0b251 --- /dev/null +++ b/internal/query/errors_test.go @@ -0,0 +1,23 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/formancehq/ledger/v3/internal/domain" +) + +func TestErrAggregateOverflow_Describable(t *testing.T) { + t.Parallel() + + err := &ErrAggregateOverflow{Stage: "collapse-colors", Side: "input"} + + require.Equal(t, "aggregate volume input overflowed 2^256 during collapse-colors", err.Error()) + require.Equal(t, domain.ErrReasonAggregateOverflow, err.Reason()) + require.Equal(t, map[string]string{"stage": "collapse-colors", "side": "input"}, err.Metadata()) + + // The reason must classify as a precondition failure through the shared + // domain kind switch, exactly as it did when the type lived in domain. + require.Equal(t, domain.KindPrecondition, domain.Kind(err)) +} diff --git a/misc/proto/bucket.proto b/misc/proto/bucket.proto index 182db43d35..cee686f3dd 100644 --- a/misc/proto/bucket.proto +++ b/misc/proto/bucket.proto @@ -125,6 +125,10 @@ message GetAccountRequest { string address = 2; // checkpoint_id, when non-zero, reads from a query checkpoint instead of the live store fixed64 checkpoint_id = 3; + // When true, colored buckets are summed into a single entry per asset + // with color = "" in the returned Account.volumes list. By default each + // (asset, color) tuple gets its own entry. + bool collapse_colors = 4; } message GetTransactionRequest { @@ -1023,7 +1027,11 @@ message AnalyzeTransactionsResponse { } message FlowPattern { - string signature = 1; // e.g. "users:{id}:main -> bank:fees [USD]" + // Human-readable canonical signature, e.g. "users:{id}:main->bank:fees[USD]" + // (legacy form, no spaces; multi-posting flows join fragments with ";"). + // Colored postings append the color inside the brackets: "...[USD/RED]". + // The uncolored bucket keeps the bare "[USD]" form, byte-identical to pre-color. + string signature = 1; PostingStructure structure = 2; fixed64 transaction_count = 3; repeated NormalizedPosting postings = 4; @@ -1036,6 +1044,11 @@ message NormalizedPosting { string source_pattern = 1; // e.g. "users:{id}:main" string destination_pattern = 2; // e.g. "bank:fees" string asset = 3; + // color discriminates postings on the same (source,destination,asset) but + // different color buckets in the FSM. Two flows that share addresses and + // asset but differ in color must produce distinct flow signatures so the + // analysis endpoint does not silently merge segregated buckets. + string color = 4; } enum PostingStructure { @@ -1216,6 +1229,10 @@ message AggregateVolumesRequest { repeated string group_by_prefixes = 5; // checkpoint_id, when non-zero, reads from a query checkpoint instead of the live store fixed64 checkpoint_id = 6; + // When true, amounts in colored buckets are summed into the uncolored bucket + // ("" color). Result entries are produced with color = "". By default each + // (asset, color) bucket yields its own AggregatedVolume entry. + bool collapse_colors = 7; } // ============================================================================ diff --git a/misc/proto/common.proto b/misc/proto/common.proto index c265327abf..d8cd45610e 100644 --- a/misc/proto/common.proto +++ b/misc/proto/common.proto @@ -62,6 +62,10 @@ message Posting { string destination = 2; Uint256 amount = 3; string asset = 4; + // Color of the funds being moved. The empty string is the "uncolored" bucket + // and is treated as just another color from a segregation standpoint. + // Color values are constrained to ^[A-Z]*$ at admission time. + string color = 5; } // Transaction represents a transaction @@ -104,12 +108,23 @@ message VolumesWithBalance { string balance = 3; // big.Int as string } -// VolumesByAssets represents volumes grouped by asset +// VolumesByAssets is a sorted list of post-commit (asset, color) volume +// entries for a single account. Sorted by (asset, color) ascending so the +// serialization is deterministic and stable across reads. message VolumesByAssets { - map volumes = 1; + repeated VolumeEntry volumes = 1; } -// PostCommitVolumes represents volumes after commit, grouped by account and asset +// VolumeEntry is one (asset, color) row inside VolumesByAssets. The empty +// color is the uncolored bucket and is itself just another segregated bucket. +message VolumeEntry { + string asset = 1; + string color = 2; + Volumes volumes = 3; +} + +// PostCommitVolumes represents volumes after commit, grouped by account. +// Within each account, entries are flat-listed per (asset, color) tuple. message PostCommitVolumes { map volumes_by_account = 1; } @@ -118,14 +133,25 @@ message PostCommitVolumes { // Account // ============================================================================ -// Account represents an account in the ledger +// AccountVolume is one (asset, color) row in Account.volumes. +message AccountVolume { + string asset = 1; + string color = 2; + VolumesWithBalance volumes = 3; +} + +// Account represents an account in the ledger. message Account { string address = 1; map metadata = 2; Timestamp first_usage = 3; Timestamp insertion_date = 4; Timestamp updated_at = 5; - map volumes = 6; // Volumes per asset + // Volumes is a sorted list of per (asset, color) entries. + // Sorted by (asset, color) ascending for stable serialization. + // When the request opts into collapse_colors, the list collapses to one + // entry per asset with color = "" and amounts summed across colors. + repeated AccountVolume volumes = 6; } // ============================================================================ @@ -646,11 +672,14 @@ message LedgerLog { } // TouchedVolume identifies a (ledger-local) volume cell — an account paired -// with an asset. Used in transient/purged volume exclusion sets where the -// indexer must distinguish per-asset state inside a multi-asset account. +// with an asset and a color. Used in transient/purged volume exclusion sets +// where the indexer must distinguish per-(asset, color) state inside a +// multi-bucket account. The empty color is the uncolored bucket and is itself +// just another segregated cell. message TouchedVolume { string account = 1; string asset = 2; + string color = 3; } message LedgerLogPayload { @@ -1193,6 +1222,8 @@ enum ErrorReason { ERROR_REASON_CHECKPOINT_NOT_READY = 63; ERROR_REASON_MIRROR_V2_LOG_ID_GAP = 64; ERROR_REASON_MIRROR_V2_LOG_ID_INVALID = 65; + ERROR_REASON_AGGREGATE_OVERFLOW = 66; + ERROR_REASON_BALANCE_NOT_FOUND = 67; } // IdempotencyFailure captures a definitive business rejection so a retried key @@ -1590,11 +1621,15 @@ message PreparedQuery { QueryTarget target = 3; } -// AggregatedVolume represents per-asset aggregated input/output volumes. +// AggregatedVolume represents aggregated input/output volumes for a single +// (asset, color) bucket. When collapse_colors is requested on the aggregate +// query, all entries are produced with color = "" and amounts summed across +// colors. message AggregatedVolume { string asset = 1; Uint256 input = 2; Uint256 output = 3; + string color = 4; } message AggregateResult { diff --git a/openapi.yml b/openapi.yml index 85388a2993..1a9bd47a2b 100644 --- a/openapi.yml +++ b/openapi.yml @@ -838,6 +838,16 @@ paths: parameters: - $ref: '#/components/parameters/LedgerName' - $ref: '#/components/parameters/AccountAddress' + - name: collapseColors + in: query + required: false + schema: + type: boolean + default: false + description: | + When true, every colored bucket of the same asset is summed + into a single AccountVolume entry with color = "". By default + each (asset, color) tuple is returned as its own entry. responses: '200': description: Account retrieved successfully @@ -895,6 +905,15 @@ paths: description: Comma-separated list of account prefixes to group volumes by schema: type: string + - name: collapseColors + in: query + description: | + When true, every colored bucket of the same (asset, precision) is + summed and the resulting AggregatedVolume entry carries color = "". + By default the response is segregated per (asset, color). + schema: + type: boolean + default: false responses: '200': description: Aggregated volumes retrieved successfully @@ -3233,7 +3252,7 @@ components: expandVolumes: type: boolean default: false - description: When true, include post-commit volumes (per account/asset) in the response + description: When true, include post-commit volumes (per account, asset and color) in the response PostingRequest: type: object @@ -3256,6 +3275,16 @@ components: asset: type: string description: Asset identifier + color: + type: string + pattern: '^[A-Z]*$' + maxLength: 32 + description: | + Optional segregation key for "color of money". The empty string + (or omitted field) means the uncolored bucket. Two postings on the + same (account, asset) but different colors operate on strictly + isolated balances. Color is constrained to `^[A-Z]*$`, capped at + 32 characters, and is immutable once carried by funds. Script: type: object @@ -3395,7 +3424,7 @@ components: expandVolumes: type: boolean default: false - description: When true, include post-commit volumes (per account/asset) in the response + description: When true, include post-commit volumes (per account, asset and color) in the response RevertTransactionRequestBody: type: object @@ -3416,7 +3445,7 @@ components: expandVolumes: type: boolean default: false - description: When true, include post-commit volumes (per account/asset) in the response + description: When true, include post-commit volumes (per account, asset and color) in the response DeleteMetadataRequest: type: object @@ -3505,7 +3534,7 @@ components: description: Metadata for accounts involved in the transaction postCommitVolumes: $ref: '#/components/schemas/PostCommitVolumes' - description: Post-commit volumes per account/asset (only present when expandVolumes is true) + description: Post-commit volumes per (account, asset, color) (only present when expandVolumes is true) chapterId: type: integer format: uint64 @@ -3618,19 +3647,34 @@ components: $ref: '#/components/schemas/MetadataValue' description: Account metadata (typed values) volumes: - type: object - additionalProperties: - $ref: '#/components/schemas/VolumesWithBalance' + type: array + items: + $ref: '#/components/schemas/AccountVolume' description: | - Per-asset input/output/balance. The field is defined here so - the schema stays stable across future wire changes, but it is - not populated on the current v3 wire — `Account.MarshalJSON` - drops it. For per-account balances, use a prepared query with - type `AGGREGATE_VOLUMES` and an exact-address filter - (`address.hardcodedExact`); the aggregated volumes returned by - `GET /v3/{ledgerName}/volumes?prefix=…` match by raw byte - prefix (`acc:1` matches `acc:10`) and will produce incorrect - balances for a single-account lookup. + Account volumes listed per (asset, color). Sorted by (asset, color) + ascending for stable serialization. The empty color is the + uncolored bucket; colored buckets are strictly isolated. Use the + `collapseColors=true` query parameter on GET /accounts to sum + every colored bucket of the same asset into a single uncolored + entry. + + AccountVolume: + type: object + required: + - asset + - color + - volumes + properties: + asset: + type: string + description: Asset identifier (e.g. "USD/2") + color: + type: string + pattern: '^[A-Z]*$' + maxLength: 32 + description: Color of the bucket. Empty string is the uncolored bucket. + volumes: + $ref: '#/components/schemas/VolumesWithBalance' VolumesWithBalance: type: object @@ -3677,11 +3721,12 @@ components: type: object description: | Per-asset aggregated volume as emitted by `GET /v3/{ledgerName}/volumes`. - Prepared-query `AGGREGATE_VOLUMES` responses use the same shape - conceptually but currently leak the raw protojson encoding (numeric - Uint256, no `balance`) — reconciliation tracked in EN-1465 / EN-1469. + Prepared-query `AGGREGATE_VOLUMES` responses (under the `aggregateResult` + envelope of `ExecutePreparedQueryResponse`) use this exact shape: + decimal-string amounts, a computed `balance`, and `color` always present. required: - asset + - color - input - output - balance @@ -3689,6 +3734,16 @@ components: asset: type: string description: Asset identifier (e.g. "USD/2") + color: + type: string + pattern: '^[A-Z]*$' + maxLength: 32 + description: | + Color of the aggregated bucket. By default the response contains + one AggregatedVolume per (asset, color) tuple. With + `collapseColors=true`, every colored bucket of the same + (asset, precision) is summed and the resulting entry carries + color = "". input: type: string description: Total amount received (as big integer string) @@ -3774,7 +3829,7 @@ components: description: The new transaction that reverses the original transaction postCommitVolumes: $ref: '#/components/schemas/PostCommitVolumes' - description: Post-commit volumes per account/asset (only present when expandVolumes is true) + description: Post-commit volumes per (account, asset, color) (only present when expandVolumes is true) warnings: type: array items: @@ -3796,6 +3851,7 @@ components: - destination - amount - asset + - color properties: source: type: string @@ -3807,6 +3863,15 @@ components: description: Amount as a big integer asset: type: string + color: + type: string + pattern: '^[A-Z]*$' + maxLength: 32 + description: | + Segregation key for "color of money". The empty string means the + uncolored bucket. Always present on responses (never omitted); two + postings on the same (account, asset) but different colors operate + on strictly isolated balances. LedgerClusterStateResponse: type: object @@ -4167,11 +4232,44 @@ components: PostCommitVolumes: type: object + description: | + Post-commit volumes, keyed by account address. Each account maps to a + flat array of per-(asset, color) volume tuples. The wire is a plain + `{"addr": [{asset, color, input, output}]}` map — the proto + `volumesByAccount` / `volumes` wrappers are flattened away by + `PostCommitVolumes.MarshalJSON`. This replaces the pre-color + `{"addr": {"asset": Volumes}}` map shape, which can no longer key a + bucket uniquely once a color dimension exists. additionalProperties: - type: object - additionalProperties: - $ref: '#/components/schemas/Volumes' - description: Post-commit volumes grouped by account address, then by asset + type: array + items: + $ref: '#/components/schemas/VolumeEntry' + + VolumeEntry: + type: object + description: | + A single flat post-commit volume tuple for one (asset, color) bucket. + Input/output are flattened onto the tuple (not nested under a `volumes` + key). The empty color is the uncolored bucket and is always emitted. + required: + - asset + - color + - input + - output + properties: + asset: + type: string + color: + type: string + pattern: '^[A-Z]*$' + maxLength: 32 + description: Empty string for the uncolored bucket; always emitted. + input: + type: string + description: big.Int as string. + output: + type: string + description: big.Int as string. Volumes: type: object @@ -4665,6 +4763,11 @@ components: NormalizedPosting: type: object + required: + - sourcePattern + - destinationPattern + - asset + - color properties: sourcePattern: type: string @@ -4672,6 +4775,15 @@ components: type: string asset: type: string + color: + type: string + pattern: '^[A-Z]*$' + maxLength: 32 + description: | + Color discriminates postings on the same (source, destination, asset) + but different color buckets in the FSM. Always emitted (even empty) + so clients can distinguish the uncolored bucket from patterns that + differ only by color. TemporalStats: type: object @@ -4959,14 +5071,50 @@ components: ExecutePreparedQueryResponse: type: object description: | - Response wraps either a paged cursor (list-style targets like - `LIST_ACCOUNTS`, `LIST_TRANSACTIONS`) or an aggregate result - (`AGGREGATE_VOLUMES`). The current wire encoding leaks the raw - protobuf oneof shape (Go-cased `Result.Cursor` / `Result.Aggregate` - with nested proto field names). Reconciling the wire with a clean - top-level `cursor` / `aggregateResult` envelope is tracked in - EN-1465; this schema will be finalised together with that fix. - additionalProperties: true + Result of executing a prepared query. Exactly one field is set, + discriminated by the request execution `mode` (see + `ExecutePreparedQueryRequest.mode`): `mode=LIST` returns `cursor`, + `mode=AGGREGATE_VOLUMES` returns `aggregateResult`. (The query `target` + — ACCOUNTS, TRANSACTIONS or LOGS — selects the row type inside the + cursor, not which envelope field is set: `accountData`, + `transactionData` or `logData` respectively. LOGS is a usable + prepared-query target: `query.Execute` hydrates `logData` for it.) Both + payloads use the same camelCase encoding as the equivalent non-prepared + endpoints + (decimal-string amounts, `color` always present on every volume row + including the uncolored bucket). + properties: + cursor: + type: object + description: | + Paged cursor, returned when the request `mode` is `LIST`. The row + type follows the query `target`: `accountData` for ACCOUNTS, + `transactionData` for TRANSACTIONS, `logData` for LOGS. Rows use the + same camelCase shapes as the non-prepared list endpoints. + properties: + pageSize: + type: integer + hasMore: + type: boolean + previous: + type: string + next: + type: string + accountData: + type: array + items: + $ref: '#/components/schemas/Account' + transactionData: + type: array + items: + $ref: '#/components/schemas/TransactionResponse' + logData: + type: array + items: + type: object + description: System log entry (protobuf JSON representation) + aggregateResult: + $ref: '#/components/schemas/AggregateVolumesData' HealthResponse: type: object diff --git a/pkg/actions/actions.go b/pkg/actions/actions.go index 728867c168..1b3ad8c35a 100644 --- a/pkg/actions/actions.go +++ b/pkg/actions/actions.go @@ -323,11 +323,16 @@ func WithExpandVolumes(req *servicepb.Request) *servicepb.Request { return req } -// NewPosting creates a new posting protobuf message. +// NewPosting creates a new uncolored posting protobuf message. func NewPosting(source, destination string, amount *big.Int, asset string) *commonpb.Posting { return commonpb.NewPosting(source, destination, asset, amount) } +// NewColoredPosting creates a new posting with an explicit color. +func NewColoredPosting(source, destination string, amount *big.Int, asset, color string) *commonpb.Posting { + return commonpb.NewColoredPosting(source, destination, asset, color, amount) +} + // RegisterSigningKeyAction creates a RegisterSigningKey request. func RegisterSigningKeyAction(keyID string, pubKey ed25519.PublicKey) *servicepb.Request { return &servicepb.Request{ diff --git a/pkg/scenario/block.go b/pkg/scenario/block.go index eadf1be646..fe7d76cd70 100644 --- a/pkg/scenario/block.go +++ b/pkg/scenario/block.go @@ -60,9 +60,16 @@ func BlockScenario(name string) string { // Helper functions for blocks to read ledger state. -// GetAccountBalance reads the balance of an account for a given asset. -// Returns (balance, true) if available, or (zero, false) otherwise. +// GetAccountBalance reads the uncolored balance of an account for a given asset. +// Returns (balance, true) if available, or (zero, false) otherwise. For a +// colored balance, see GetColoredAccountBalance. func GetAccountBalance(ctx context.Context, client servicepb.BucketServiceClient, ledger, address, asset string) (*big.Int, bool) { + return GetColoredAccountBalance(ctx, client, ledger, address, asset, "") +} + +// GetColoredAccountBalance reads the balance of an account for a given +// (asset, color) bucket. Color "" is the uncolored bucket. +func GetColoredAccountBalance(ctx context.Context, client servicepb.BucketServiceClient, ledger, address, asset, color string) (*big.Int, bool) { acct, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{ Ledger: ledger, Address: address, @@ -70,16 +77,19 @@ func GetAccountBalance(ctx context.Context, client servicepb.BucketServiceClient if err != nil { return big.NewInt(0), false } - vol, ok := acct.GetVolumes()[asset] - if !ok { - return big.NewInt(0), false - } - balance, ok := new(big.Int).SetString(vol.GetBalance(), 10) - if !ok { - return big.NewInt(0), false + for _, entry := range acct.GetVolumes() { + if entry.GetAsset() != asset || entry.GetColor() != color { + continue + } + balance, ok := new(big.Int).SetString(entry.GetVolumes().GetBalance(), 10) + if !ok { + return big.NewInt(0), false + } + + return balance, true } - return balance, true + return big.NewInt(0), false } // GetNonRevertedTransaction finds a random non-reverted transaction in a ledger. diff --git a/tests/antithesis/workload/bin/cmds/main/eventually_correct/main.go b/tests/antithesis/workload/bin/cmds/main/eventually_correct/main.go index ebdb843f53..4f1980ecc0 100644 --- a/tests/antithesis/workload/bin/cmds/main/eventually_correct/main.go +++ b/tests/antithesis/workload/bin/cmds/main/eventually_correct/main.go @@ -136,14 +136,17 @@ func checkBalanced(ctx context.Context, client servicepb.BucketServiceClient, le }) } - aggregated := make(map[string]*big.Int) + // Double-entry holds per (asset, color) bucket. + type aggKey struct{ asset, color string } + aggregated := make(map[aggKey]*big.Int) for _, account := range accounts { - for asset, vol := range account.Volumes { - if aggregated[asset] == nil { - aggregated[asset] = big.NewInt(0) + for _, entry := range account.GetVolumes() { + k := aggKey{asset: entry.GetAsset(), color: entry.GetColor()} + if aggregated[k] == nil { + aggregated[k] = big.NewInt(0) } - aggregated[asset].Add(aggregated[asset], parseBalance(vol.GetBalance())) + aggregated[k].Add(aggregated[k], parseBalance(entry.GetVolumes().GetBalance())) } } @@ -219,7 +222,10 @@ func checkVolumesConsistent(ctx context.Context, client servicepb.BucketServiceC checked := false for _, account := range accounts { - for asset, vol := range account.Volumes { + for _, entry := range account.GetVolumes() { + asset := entry.GetAsset() + color := entry.GetColor() + vol := entry.GetVolumes() input := parseBalance(vol.GetInput()) output := parseBalance(vol.GetOutput()) balance := parseBalance(vol.GetBalance()) @@ -227,6 +233,7 @@ func checkVolumesConsistent(ctx context.Context, client servicepb.BucketServiceC internal.CheckVolume(input, output, balance, details.With(internal.Details{ "account": account.Address, "asset": asset, + "color": color, })) getAcc, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{ @@ -244,11 +251,13 @@ func checkVolumesConsistent(ctx context.Context, client servicepb.BucketServiceC continue } - actualVol, ok := getAcc.Volumes[asset] - if !ok { + // Cross-check the same (asset, color) bucket as the list result. + actualVol := getAcc.FindVolume(asset, color) + if actualVol == nil { assert.Unreachable("should get requested volumes", details.With(internal.Details{ "account": account.Address, "asset": asset, + "color": color, })) continue diff --git a/tests/antithesis/workload/bin/cmds/main/eventually_cross_node_identity/main.go b/tests/antithesis/workload/bin/cmds/main/eventually_cross_node_identity/main.go index 6958961da8..1cd1739bce 100644 --- a/tests/antithesis/workload/bin/cmds/main/eventually_cross_node_identity/main.go +++ b/tests/antithesis/workload/bin/cmds/main/eventually_cross_node_identity/main.go @@ -357,8 +357,8 @@ func volumesString(acc *commonpb.Account) string { } var out strings.Builder - for asset, v := range acc.GetVolumes() { - fmt.Fprintf(&out, "%s=%s ", asset, v.GetBalance()) + for _, v := range acc.GetVolumes() { + fmt.Fprintf(&out, "%s/%s=%s ", v.GetAsset(), v.GetColor(), v.GetVolumes().GetBalance()) } return out.String() diff --git a/tests/antithesis/workload/bin/cmds/main/parallel_driver_stale_reads/main.go b/tests/antithesis/workload/bin/cmds/main/parallel_driver_stale_reads/main.go index c26f12a86b..8a0212fd3f 100644 --- a/tests/antithesis/workload/bin/cmds/main/parallel_driver_stale_reads/main.go +++ b/tests/antithesis/workload/bin/cmds/main/parallel_driver_stale_reads/main.go @@ -142,7 +142,7 @@ func main() { "acked": acked, } - vol := account.GetVolumes()[probeAsset] + vol := account.FindVolume(probeAsset, "") if vol == nil { // Valid prefix: account materialized, no write applied yet. continue diff --git a/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/reads.go b/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/reads.go index 5a394da226..af36cd678c 100644 --- a/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/reads.go +++ b/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/reads.go @@ -125,14 +125,16 @@ func pickCell(g oracle.GlobalState) (ledger, addr, asset string, ok bool) { } // accountAssetVolumes extracts (input, output) for one asset from a GetAccount -// response. found=false when the asset entry is missing. +// response. The workload only ever exercises uncolored postings, so we look +// up the uncolored bucket (color="") explicitly — colored buckets are out of +// scope for this driver model. found=false when the bucket is missing. func accountAssetVolumes(acct *commonpb.Account, asset string) (in, out uint256.Int, found bool) { if acct == nil { return in, out, false } - v, ok := acct.GetVolumes()[asset] - if !ok { + v := acct.FindVolume(asset, "") + if v == nil { return in, out, false } diff --git a/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/validate.go b/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/validate.go index c0cf97e6cb..c3bb14eb07 100644 --- a/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/validate.go +++ b/tests/antithesis/workload/bin/cmds/model/singleton_driver_model/validate.go @@ -781,15 +781,26 @@ func accountMetaMapEqual(a, b map[string]*commonpb.MetadataMap) bool { // postCommitVolume extracts (input, output) for one cell from a server response, // parsing the decimal-string volumes into uint256 — the ledger's native volume -// type. ok is false when the cell is absent or the values don't parse. +// type. The workload only ever exercises uncolored postings, so we match the +// uncolored bucket (color="") explicitly — colored buckets are out of scope +// for this driver model. ok is false when the cell is absent or the values +// don't parse. func postCommitVolume(pcv *commonpb.PostCommitVolumes, key oracle.VolumeKey) (in, out uint256.Int, ok bool) { byAsset, found := pcv.GetVolumesByAccount()[key.Address] if !found { return in, out, false } - vol, found := byAsset.GetVolumes()[key.Asset] - if !found { + var vol *commonpb.Volumes + for _, entry := range byAsset.GetVolumes() { + if entry.GetAsset() == key.Asset && entry.GetColor() == "" { + vol = entry.GetVolumes() + + break + } + } + + if vol == nil { return in, out, false } diff --git a/tests/antithesis/workload/go.mod b/tests/antithesis/workload/go.mod index 5436ff11cb..9405e09654 100644 --- a/tests/antithesis/workload/go.mod +++ b/tests/antithesis/workload/go.mod @@ -62,7 +62,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/formancehq/invariants v0.11.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getsentry/sentry-go v0.35.1 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -113,7 +113,7 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect diff --git a/tests/antithesis/workload/go.sum b/tests/antithesis/workload/go.sum index c9b5fedb3e..df259178d2 100644 --- a/tests/antithesis/workload/go.sum +++ b/tests/antithesis/workload/go.sum @@ -103,12 +103,12 @@ github.com/formancehq/go-libs/v5 v5.7.0 h1:2Z2S3vtOJr45tKpofhPqd0qKrPr6KB/LZZ+Ef github.com/formancehq/go-libs/v5 v5.7.0/go.mod h1:+jfCYWJ4Z10NGbhmbfon0hGoLe5pysbVQgePrg8M8W4= github.com/formancehq/invariants v0.11.0 h1:AHFBhK7U8Ak/qoJwgaNU+cAb9w61Ba7dwlcrJTkWBE4= github.com/formancehq/invariants v0.11.0/go.mod h1:ywzSexCUdhw2dC9Njiv0t4u2KfPVKj6SecFujPgmXno= -github.com/formancehq/numscript v0.0.24 h1:YBiDZ9zLVxTZVhtQ+taRcb6q2jArAvznWMfoWRVYGT0= -github.com/formancehq/numscript v0.0.24/go.mod h1:hC/VY5Vg04F5QkgdPPc6z/YsS/vh8V1qVJVa1VWnYMA= +github.com/formancehq/numscript v0.0.25-0.20260615131322-cbc11e844233 h1:uRt0sq1nV+emELMBtKvgHhb0aERyioLl7v9rJwINhWw= +github.com/formancehq/numscript v0.0.25-0.20260615131322-cbc11e844233/go.mod h1:DUR23FgL3NVEYvjr8KHt5bGFDpcheydwbvs+iTGohAU= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ= -github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -289,8 +289,8 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/tests/antithesis/workload/internal/checks.go b/tests/antithesis/workload/internal/checks.go index 1ac36547aa..c076db6e15 100644 --- a/tests/antithesis/workload/internal/checks.go +++ b/tests/antithesis/workload/internal/checks.go @@ -19,9 +19,11 @@ func CheckVolume(input, output, balance *big.Int, details Details) { })) } -// CheckAccountVolumes verifies volume consistency for all assets of an account. -func CheckAccountVolumes(volumes map[string]*commonpb.VolumesWithBalance, details Details) { - for asset, vol := range volumes { +// CheckAccountVolumes verifies volume consistency for every (asset, color) +// bucket on an account. +func CheckAccountVolumes(volumes []*commonpb.AccountVolume, details Details) { + for _, entry := range volumes { + vol := entry.GetVolumes() input, _ := new(big.Int).SetString(vol.GetInput(), 10) output, _ := new(big.Int).SetString(vol.GetOutput(), 10) balance, _ := new(big.Int).SetString(vol.GetBalance(), 10) @@ -35,18 +37,21 @@ func CheckAccountVolumes(volumes map[string]*commonpb.VolumesWithBalance, detail balance = big.NewInt(0) } CheckVolume(input, output, balance, details.With(Details{ - "asset": asset, + "asset": entry.GetAsset(), + "color": entry.GetColor(), })) } } // CheckPostCommitVolumes verifies volume consistency for post-commit volumes from a transaction response. +// Each (asset, color) bucket is verified independently. func CheckPostCommitVolumes(pcv *commonpb.PostCommitVolumes, details Details) { if pcv == nil { return } for account, volumesByAssets := range pcv.GetVolumesByAccount() { - for asset, vol := range volumesByAssets.GetVolumes() { + for _, entry := range volumesByAssets.GetVolumes() { + vol := entry.GetVolumes() input, _ := new(big.Int).SetString(vol.GetInput(), 10) output, _ := new(big.Int).SetString(vol.GetOutput(), 10) if input == nil { @@ -58,7 +63,8 @@ func CheckPostCommitVolumes(pcv *commonpb.PostCommitVolumes, details Details) { balance := new(big.Int).Sub(input, output) CheckVolume(input, output, balance, details.With(Details{ "account": account, - "asset": asset, + "asset": entry.GetAsset(), + "color": entry.GetColor(), })) } } diff --git a/tests/e2e/business/barrier_test.go b/tests/e2e/business/barrier_test.go index 02392d7f91..190a207b1f 100644 --- a/tests/e2e/business/barrier_test.go +++ b/tests/e2e/business/barrier_test.go @@ -43,7 +43,8 @@ var _ = Describe("Barrier", Ordered, func() { }) Expect(err).To(Succeed()) Expect(account).NotTo(BeNil()) - Expect(account.Volumes).To(HaveKey("USD")) - Expect(account.Volumes["USD"].GetBalance()).To(Equal("500")) + usdVol := account.FindVolume("USD", "") + Expect(usdVol).NotTo(BeNil(), "expected USD volumes on barrier-test-account") + Expect(usdVol.GetBalance()).To(Equal("500")) }) }) diff --git a/tests/e2e/business/color_numscript_test.go b/tests/e2e/business/color_numscript_test.go new file mode 100644 index 0000000000..095f16aafb --- /dev/null +++ b/tests/e2e/business/color_numscript_test.go @@ -0,0 +1,326 @@ +//go:build e2e + +package business + +import ( + "math/big" + "time" + + "github.com/formancehq/ledger/v3/internal/proto/commonpb" + "github.com/formancehq/ledger/v3/internal/proto/servicepb" + "github.com/formancehq/ledger/v3/pkg/actions" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Color × Numscript: the color dimension flows through the Numscript adapter +// the same way it flows through native postings. A script restricting its +// source to a color must consume from exactly that bucket, and the resulting +// posting must carry the color all the way to the FSM and back through the +// read side. +var _ = Describe("ColorNumscript", Ordered, func() { + const ledgerName = "color-numscript" + + BeforeAll(func() { + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateLedgerAction(ledgerName, nil), + )) + Expect(err).To(Succeed()) + + // Seed alice with three segregated buckets on USD/2: + // uncolored "" : 300 + // GRANTS : 200 + // OPS : 100 + _, err = sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("world", "alice", big.NewInt(300), "USD/2", ""), + actions.NewColoredPosting("world", "alice", big.NewInt(200), "USD/2", "GRANTS"), + actions.NewColoredPosting("world", "alice", big.NewInt(100), "USD/2", "OPS"), + }, nil, nil), + )) + Expect(err).To(Succeed()) + }) + + It("Should restrict the numscript source to a single color bucket", func() { + // Draw 60 from alice's GRANTS bucket via Numscript. The resulting + // posting must carry color = "GRANTS", and only the GRANTS bucket + // must move — uncolored and OPS must be untouched. + script := ` +#![feature("experimental-asset-colors")] + +send [USD/2 60] ( + source = @alice \ "GRANTS" + destination = @bob +) +` + resp, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateScriptTransactionAction(ledgerName, script, nil, nil), + )) + Expect(err).To(Succeed()) + Expect(resp.Logs).To(HaveLen(1)) + + createdTx := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction() + Expect(createdTx).NotTo(BeNil()) + Expect(createdTx.Transaction.Postings).To(HaveLen(1)) + Expect(createdTx.Transaction.Postings[0].GetSource()).To(Equal("alice")) + Expect(createdTx.Transaction.Postings[0].GetDestination()).To(Equal("bob")) + Expect(createdTx.Transaction.Postings[0].GetAsset()).To(Equal("USD/2")) + Expect(createdTx.Transaction.Postings[0].GetColor()).To(Equal("GRANTS"), + "the color restriction on the source must propagate to the emitted posting") + Expect(createdTx.Transaction.Postings[0].Amount.ToBigInt().Int64()).To(Equal(int64(60))) + + Eventually(func(g Gomega) { + alice, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + }) + g.Expect(err).To(Succeed()) + + // GRANTS drained by 60 (200 - 60 = 140); others unchanged. + g.Expect(alice.FindVolume("USD/2", "GRANTS").GetBalance()).To(Equal("140")) + g.Expect(alice.FindVolume("USD/2", "").GetBalance()).To(Equal("300")) + g.Expect(alice.FindVolume("USD/2", "OPS").GetBalance()).To(Equal("100")) + + // bob received under the same color and only under that color. + bob, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "bob", + }) + g.Expect(err).To(Succeed()) + g.Expect(bob.FindVolume("USD/2", "GRANTS").GetBalance()).To(Equal("60")) + g.Expect(bob.FindVolume("USD/2", "")).To(BeNil(), + "the color stays with the funds — bob must not have an uncolored bucket") + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) + + It("Should refuse a numscript draw when the colored bucket is empty", func() { + // MISSING is a color alice never received; the script must fail + // rather than fall back to other buckets. + script := ` +#![feature("experimental-asset-colors")] + +send [USD/2 1] ( + source = @alice \ "MISSING" + destination = @bob +) +` + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateScriptTransactionAction(ledgerName, script, nil, nil), + )) + Expect(err).To(HaveOccurred(), + "a colored draw must not be satisfied from uncolored funds even via numscript") + }) + + It("Should treat the uncolored bucket as its own color in numscript", func() { + // Drawing from @alice without a restrict must consume the + // uncolored bucket only — the colored buckets stay segregated + // from the default scope. + script := ` +send [USD/2 90] ( + source = @alice + destination = @bob +) +` + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateScriptTransactionAction(ledgerName, script, nil, nil), + )) + Expect(err).To(Succeed()) + + Eventually(func(g Gomega) { + alice, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + }) + g.Expect(err).To(Succeed()) + + // Uncolored shrunk by 90 (300 - 90 = 210); colored stays intact. + g.Expect(alice.FindVolume("USD/2", "").GetBalance()).To(Equal("210")) + g.Expect(alice.FindVolume("USD/2", "GRANTS").GetBalance()).To(Equal("140")) + g.Expect(alice.FindVolume("USD/2", "OPS").GetBalance()).To(Equal("100")) + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) + + It("Should expose every (asset, color) bucket when GetAccount is called without collapse", func() { + acct, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + }) + Expect(err).To(Succeed()) + + // Volumes list is sorted (asset, color); three buckets for alice. + vols := acct.GetVolumes() + Expect(vols).To(HaveLen(3)) + Expect(vols[0].GetAsset()).To(Equal("USD/2")) + Expect(vols[0].GetColor()).To(Equal("")) + Expect(vols[0].GetVolumes().GetBalance()).To(Equal("210")) + Expect(vols[1].GetColor()).To(Equal("GRANTS")) + Expect(vols[1].GetVolumes().GetBalance()).To(Equal("140")) + Expect(vols[2].GetColor()).To(Equal("OPS")) + Expect(vols[2].GetVolumes().GetBalance()).To(Equal("100")) + }) + + It("Should collapse colors on GetAccount when collapseColors=true", func() { + acct, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + CollapseColors: true, + }) + Expect(err).To(Succeed()) + + // All three buckets summed under color = "" (210 + 140 + 100 = 450) + Expect(acct.GetVolumes()).To(HaveLen(1)) + entry := acct.GetVolumes()[0] + Expect(entry.GetAsset()).To(Equal("USD/2")) + Expect(entry.GetColor()).To(Equal("")) + Expect(entry.GetVolumes().GetBalance()).To(Equal("450")) + }) +}) + +// AggregateVolumes × color: the aggregate endpoint preserves the color +// dimension by default, and collapses it to a single per-asset entry when +// asked. These tests pin both shapes to lock the contract. +var _ = Describe("AggregateVolumesColor", Ordered, func() { + const ledgerName = "agg-vol-color" + + BeforeAll(func() { + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateLedgerAction(ledgerName, nil), + )) + Expect(err).To(Succeed()) + + // Fund three (asset, color) buckets on alice. We mix assets and + // colors so the aggregator must handle both axes: + // alice / USD/2 / "" : 10 + // alice / USD/2 / "GRANTS" : 100 + // alice / USD/2 / "OPS" : 40 + // alice / EUR/2 / "GRANTS" : 50 + _, err = sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("world", "alice", big.NewInt(10), "USD/2", ""), + actions.NewColoredPosting("world", "alice", big.NewInt(100), "USD/2", "GRANTS"), + actions.NewColoredPosting("world", "alice", big.NewInt(40), "USD/2", "OPS"), + actions.NewColoredPosting("world", "alice", big.NewInt(50), "EUR/2", "GRANTS"), + }, nil, nil), + )) + Expect(err).To(Succeed()) + }) + + It("Should return one entry per (asset, color) by default", func() { + Eventually(func(g Gomega) { + result, err := sharedClient.AggregateVolumes(sharedCtx, &servicepb.AggregateVolumesRequest{ + Ledger: ledgerName, + }) + g.Expect(err).To(Succeed()) + // 3 USD/2 buckets + 1 EUR/2 bucket = 4 entries. + g.Expect(result.Volumes).To(HaveLen(4)) + + type key struct{ asset, color string } + byKey := make(map[key]*commonpb.AggregatedVolume, len(result.Volumes)) + for _, v := range result.Volumes { + byKey[key{v.GetAsset(), v.GetColor()}] = v + } + + usdUncolored := byKey[key{"USD/2", ""}] + g.Expect(usdUncolored).NotTo(BeNil()) + g.Expect(usdUncolored.Input.ToBigInt().Int64()).To(Equal(int64(10))) + g.Expect(usdUncolored.Output.ToBigInt().Int64()).To(Equal(int64(10)), + "world's output for USD/2 uncolored must match alice's input") + + usdGrants := byKey[key{"USD/2", "GRANTS"}] + g.Expect(usdGrants).NotTo(BeNil()) + g.Expect(usdGrants.Input.ToBigInt().Int64()).To(Equal(int64(100))) + + usdOps := byKey[key{"USD/2", "OPS"}] + g.Expect(usdOps).NotTo(BeNil()) + g.Expect(usdOps.Input.ToBigInt().Int64()).To(Equal(int64(40))) + + eurGrants := byKey[key{"EUR/2", "GRANTS"}] + g.Expect(eurGrants).NotTo(BeNil()) + g.Expect(eurGrants.Input.ToBigInt().Int64()).To(Equal(int64(50))) + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) + + It("Should collapse colors to one entry per asset when collapseColors=true", func() { + Eventually(func(g Gomega) { + result, err := sharedClient.AggregateVolumes(sharedCtx, &servicepb.AggregateVolumesRequest{ + Ledger: ledgerName, + CollapseColors: true, + }) + g.Expect(err).To(Succeed()) + // USD/2 (10+100+40 = 150) and EUR/2 (50), each under color = "". + g.Expect(result.Volumes).To(HaveLen(2)) + + byAsset := make(map[string]*commonpb.AggregatedVolume, len(result.Volumes)) + for _, v := range result.Volumes { + g.Expect(v.GetColor()).To(Equal(""), + "collapse must surface every aggregated entry under the empty color bucket") + byAsset[v.GetAsset()] = v + } + + usd := byAsset["USD/2"] + g.Expect(usd).NotTo(BeNil()) + g.Expect(usd.Input.ToBigInt().Int64()).To(Equal(int64(150))) + g.Expect(usd.Output.ToBigInt().Int64()).To(Equal(int64(150)), + "world's output must collapse the same way alice's input does") + + eur := byAsset["EUR/2"] + g.Expect(eur).NotTo(BeNil()) + g.Expect(eur.Input.ToBigInt().Int64()).To(Equal(int64(50))) + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) +}) + +// Reverting a colored transaction must return the funds to the same color +// bucket they came from — not to the uncolored default. Pins the contract +// that Color carries through the revert path. +var _ = Describe("ColorRevert", Ordered, func() { + const ledgerName = "color-revert" + + var revertTargetTxID uint64 + + BeforeAll(func() { + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateLedgerAction(ledgerName, nil), + )) + Expect(err).To(Succeed()) + + // world → alice 200 USD/2 color=GRANTS, world → alice 100 USD/2 uncolored. + _, err = sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("world", "alice", big.NewInt(100), "USD/2", ""), + }, nil, nil), + )) + Expect(err).To(Succeed()) + + // The colored transaction is the one we'll revert. + resp, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("world", "alice", big.NewInt(200), "USD/2", "GRANTS"), + }, nil, nil), + )) + Expect(err).To(Succeed()) + Expect(resp.Logs).To(HaveLen(1)) + createdTx := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction() + Expect(createdTx).NotTo(BeNil()) + revertTargetTxID = createdTx.Transaction.GetId() + }) + + It("Should drive the revert against the same color bucket", func() { + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.RevertTransactionAction(ledgerName, revertTargetTxID, false, false, nil), + )) + Expect(err).To(Succeed()) + + Eventually(func(g Gomega) { + alice, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + }) + g.Expect(err).To(Succeed()) + + // GRANTS bucket back to 0 (200 - 200); uncolored untouched at 100. + g.Expect(alice.FindVolume("USD/2", "GRANTS").GetBalance()).To(Equal("0")) + g.Expect(alice.FindVolume("USD/2", "").GetBalance()).To(Equal("100")) + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) +}) diff --git a/tests/e2e/business/color_segregation_test.go b/tests/e2e/business/color_segregation_test.go new file mode 100644 index 0000000000..1ff027e6cf --- /dev/null +++ b/tests/e2e/business/color_segregation_test.go @@ -0,0 +1,166 @@ +//go:build e2e + +package business + +import ( + "math/big" + "time" + + "github.com/formancehq/ledger/v3/internal/proto/commonpb" + "github.com/formancehq/ledger/v3/internal/proto/servicepb" + "github.com/formancehq/ledger/v3/pkg/actions" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Color segregation invariant: a posting on (account, asset, color) only ever +// touches that bucket. Funds in color=GRANTS cannot satisfy a draw from +// color=OPS, and the uncolored bucket is itself segregated from any colored +// bucket. This test drives the invariant end to end via gRPC. +var _ = Describe("ColorSegregation", Ordered, func() { + const ledgerName = "color-segregation" + + BeforeAll(func() { + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateLedgerAction(ledgerName, nil), + )) + Expect(err).To(Succeed()) + + // Seed alice with three segregated buckets on USD/2: + // uncolored "" : 100 + // GRANTS : 50 + // OPS : 25 + _, err = sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("world", "alice", big.NewInt(100), "USD/2", ""), + actions.NewColoredPosting("world", "alice", big.NewInt(50), "USD/2", "GRANTS"), + actions.NewColoredPosting("world", "alice", big.NewInt(25), "USD/2", "OPS"), + }, nil, nil), + )) + Expect(err).To(Succeed()) + }) + + It("Should expose every (asset, color) bucket on GetAccount", func() { + Eventually(func(g Gomega) { + acct, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + }) + g.Expect(err).To(Succeed()) + + uncolored := acct.FindVolume("USD/2", "") + grants := acct.FindVolume("USD/2", "GRANTS") + ops := acct.FindVolume("USD/2", "OPS") + + g.Expect(uncolored).NotTo(BeNil()) + g.Expect(grants).NotTo(BeNil()) + g.Expect(ops).NotTo(BeNil()) + + g.Expect(uncolored.GetBalance()).To(Equal("100")) + g.Expect(grants.GetBalance()).To(Equal("50")) + g.Expect(ops.GetBalance()).To(Equal("25")) + + // volumes list must be sorted deterministically by (asset, color) + vols := acct.GetVolumes() + g.Expect(vols).To(HaveLen(3)) + g.Expect(vols[0].GetColor()).To(Equal("")) + g.Expect(vols[1].GetColor()).To(Equal("GRANTS")) + g.Expect(vols[2].GetColor()).To(Equal("OPS")) + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) + + It("Should collapse colors into a single per-asset entry when requested", func() { + acct, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + CollapseColors: true, + }) + Expect(err).To(Succeed()) + + // All three buckets summed under color = "" + Expect(acct.GetVolumes()).To(HaveLen(1)) + entry := acct.GetVolumes()[0] + Expect(entry.GetAsset()).To(Equal("USD/2")) + Expect(entry.GetColor()).To(Equal("")) + Expect(entry.GetVolumes().GetBalance()).To(Equal("175")) // 100 + 50 + 25 + }) + + It("Should reject a draw from a color that has insufficient funds", func() { + // alice's OPS bucket has 25; ask for 100 OPS → MissingFunds. + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("alice", "bob", big.NewInt(100), "USD/2", "OPS"), + }, nil, nil), + )) + Expect(err).NotTo(BeNil(), "expected color isolation to refuse spending more than the bucket holds") + }) + + It("Should refuse drawing colored funds from the uncolored bucket", func() { + // alice's uncolored bucket has 100; drawing 100 from "" should succeed. + // But drawing 100 from "GRANTS" must NOT dip into the 100 uncolored. + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("alice", "bob", big.NewInt(100), "USD/2", "GRANTS"), + }, nil, nil), + )) + Expect(err).NotTo(BeNil(), "uncolored funds must not satisfy a GRANTS-colored draw") + }) + + It("Should drain a color independently of the others", func() { + // Spend exactly the GRANTS bucket (50) → success. + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", + actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ + actions.NewColoredPosting("alice", "bob", big.NewInt(50), "USD/2", "GRANTS"), + }, nil, nil), + )) + Expect(err).To(Succeed()) + + Eventually(func(g Gomega) { + alice, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "alice", + }) + g.Expect(err).To(Succeed()) + + // GRANTS is drained; other buckets are untouched. + g.Expect(alice.FindVolume("USD/2", "GRANTS").GetBalance()).To(Equal("0")) + g.Expect(alice.FindVolume("USD/2", "").GetBalance()).To(Equal("100")) + g.Expect(alice.FindVolume("USD/2", "OPS").GetBalance()).To(Equal("25")) + + // bob received under GRANTS, color preserved on the destination side. + bob, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "bob", + }) + g.Expect(err).To(Succeed()) + g.Expect(bob.FindVolume("USD/2", "GRANTS").GetBalance()).To(Equal("50")) + g.Expect(bob.FindVolume("USD/2", "")).To(BeNil(), + "bob must not have an uncolored USD/2 bucket — color stays with the funds") + }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) + }) + + It("Should expose color on the emitted posting", func() { + // Re-fetch the GRANTS transfer we just executed and verify the + // posting still carries color = "GRANTS" on the wire. + resp, err := sharedClient.ListTransactions(sharedCtx, &servicepb.ListTransactionsRequest{ + Ledger: ledgerName, + Options: &commonpb.ListOptions{PageSize: 32}, + }) + Expect(err).To(Succeed()) + + var foundColored bool + for { + tx, recvErr := resp.Recv() + if recvErr != nil { + break + } + for _, p := range tx.GetPostings() { + if p.GetColor() == "GRANTS" { + foundColored = true + } + } + } + Expect(foundColored).To(BeTrue(), + "expected at least one persisted posting with color = GRANTS") + }) +}) diff --git a/tests/e2e/business/ephemeral_purge_test.go b/tests/e2e/business/ephemeral_purge_test.go index b263baf1c4..b039611bee 100644 --- a/tests/e2e/business/ephemeral_purge_test.go +++ b/tests/e2e/business/ephemeral_purge_test.go @@ -88,8 +88,8 @@ var _ = Describe("EphemeralPurge", Ordered, func() { }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue(), "expected USD volumes on bank:main") + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil(), "expected USD volumes on bank:main") g.Expect(usdVol.GetInput()).To(Equal("100")) g.Expect(usdVol.GetBalance()).To(Equal("100")) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) @@ -110,8 +110,8 @@ var _ = Describe("EphemeralPurge", Ordered, func() { }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue(), "expected USD volumes after reuse") + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil(), "expected USD volumes after reuse") g.Expect(usdVol.GetInput()).To(Equal("50")) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) }) @@ -143,8 +143,8 @@ var _ = Describe("EphemeralPurge", Ordered, func() { }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue(), "expected USD volumes on non-ephemeral account") + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil(), "expected USD volumes on non-ephemeral account") g.Expect(usdVol.GetInput()).To(Equal("100")) g.Expect(usdVol.GetOutput()).To(Equal("100")) g.Expect(usdVol.GetBalance()).To(Equal("0")) diff --git a/tests/e2e/business/force_test.go b/tests/e2e/business/force_test.go index b4f4261d58..52ed85e88d 100644 --- a/tests/e2e/business/force_test.go +++ b/tests/e2e/business/force_test.go @@ -35,7 +35,7 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "limited-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Balance).To(Equal("100")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("100")) // Try to send more than available without force - should fail _, err = sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ @@ -82,7 +82,7 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "empty-account", }) Expect(err).To(Succeed()) - Expect(sourceAccount.Volumes["USD"].Balance).To(Equal("-100")) + Expect(sourceAccount.FindVolume("USD", "").Balance).To(Equal("-100")) // The destination account should have positive balance destAccount, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ @@ -90,7 +90,7 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "zero-dest", }) Expect(err).To(Succeed()) - Expect(destAccount.Volumes["USD"].Balance).To(Equal("100")) + Expect(destAccount.FindVolume("USD", "").Balance).To(Equal("100")) }) It("Should create multiple postings with force=true", func() { @@ -119,7 +119,7 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: tc.addr, }) Expect(err).To(Succeed()) - Expect(account.Volumes[tc.asset].Balance).To(Equal(tc.balance)) + Expect(account.FindVolume(tc.asset, "").GetBalance()).To(Equal(tc.balance)) } }) @@ -275,9 +275,9 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "empty-source", }) Expect(err).To(Succeed()) - Expect(source.Volumes["USD"].Input).To(Equal("0")) - Expect(source.Volumes["USD"].Output).To(Equal("500")) - Expect(source.Volumes["USD"].Balance).To(Equal("-500")) + Expect(source.FindVolume("USD", "").Input).To(Equal("0")) + Expect(source.FindVolume("USD", "").Output).To(Equal("500")) + Expect(source.FindVolume("USD", "").Balance).To(Equal("-500")) // Check target account has positive balance target, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ @@ -285,9 +285,9 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "target", }) Expect(err).To(Succeed()) - Expect(target.Volumes["USD"].Input).To(Equal("500")) - Expect(target.Volumes["USD"].Output).To(Equal("0")) - Expect(target.Volumes["USD"].Balance).To(Equal("500")) + Expect(target.FindVolume("USD", "").Input).To(Equal("500")) + Expect(target.FindVolume("USD", "").Output).To(Equal("0")) + Expect(target.FindVolume("USD", "").Balance).To(Equal("500")) }) It("Should allow subsequent force transactions to accumulate debt", func() { @@ -305,8 +305,8 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "debt-source", }) Expect(err).To(Succeed()) - Expect(source.Volumes["USD"].Output).To(Equal("300")) - Expect(source.Volumes["USD"].Balance).To(Equal("-300")) + Expect(source.FindVolume("USD", "").Output).To(Equal("300")) + Expect(source.FindVolume("USD", "").Balance).To(Equal("-300")) }) It("Should allow force transactions to recover from negative balance", func() { @@ -322,7 +322,7 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "recovery-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Balance).To(Equal("-500")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("-500")) // Fund the account to recover _, err = sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ @@ -336,9 +336,9 @@ var _ = Describe("Force Transactions", Ordered, func() { Address: "recovery-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Input).To(Equal("1000")) - Expect(account.Volumes["USD"].Output).To(Equal("500")) - Expect(account.Volumes["USD"].Balance).To(Equal("500")) + Expect(account.FindVolume("USD", "").Input).To(Equal("1000")) + Expect(account.FindVolume("USD", "").Output).To(Equal("500")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("500")) }) }) }) diff --git a/tests/e2e/business/idempotency_failure_test.go b/tests/e2e/business/idempotency_failure_test.go index a8abf154df..0d861f1189 100644 --- a/tests/e2e/business/idempotency_failure_test.go +++ b/tests/e2e/business/idempotency_failure_test.go @@ -152,8 +152,8 @@ var _ = Describe("Idempotency preserves committed outcomes", Ordered, func() { Address: "wallet:1", }) Expect(err).To(Succeed()) - usdVol, ok := acct.GetVolumes()["USD"] - Expect(ok).To(BeTrue()) + usdVol := acct.FindVolume("USD", "") + Expect(usdVol).NotTo(BeNil()) Expect(usdVol.GetInput()).To(Equal("50")) }) }) diff --git a/tests/e2e/business/idempotency_test.go b/tests/e2e/business/idempotency_test.go index 5ddfe1f674..dc6c4e4cb4 100644 --- a/tests/e2e/business/idempotency_test.go +++ b/tests/e2e/business/idempotency_test.go @@ -114,7 +114,7 @@ var _ = Describe("Idempotency Keys", Ordered, func() { Address: "account-dup", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Input).To(Equal("100")) + Expect(account.FindVolume("USD", "").Input).To(Equal("100")) }) It("Should fail when reusing idempotency key with different transaction content", func() { @@ -159,7 +159,7 @@ var _ = Describe("Idempotency Keys", Ordered, func() { Address: "account-multi", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Input).To(Equal("200")) + Expect(account.FindVolume("USD", "").Input).To(Equal("200")) }) }) @@ -225,7 +225,7 @@ var _ = Describe("Idempotency Keys", Ordered, func() { Address: "ttl-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Input).To(Equal("200")) + Expect(account.FindVolume("USD", "").Input).To(Equal("200")) }) }) @@ -282,14 +282,14 @@ var _ = Describe("Idempotency Keys", Ordered, func() { Address: "bulk-account-1", }) Expect(err).To(Succeed()) - Expect(account1.Volumes["USD"].Input).To(Equal("100")) + Expect(account1.FindVolume("USD", "").Input).To(Equal("100")) account2, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ Ledger: ledgerName, Address: "bulk-account-2", }) Expect(err).To(Succeed()) - Expect(account2.Volumes["USD"].Input).To(Equal("200")) + Expect(account2.FindVolume("USD", "").Input).To(Equal("200")) }) }) }) diff --git a/tests/e2e/business/ledger_delete_cleanup_test.go b/tests/e2e/business/ledger_delete_cleanup_test.go index 7302039229..65b9e82767 100644 --- a/tests/e2e/business/ledger_delete_cleanup_test.go +++ b/tests/e2e/business/ledger_delete_cleanup_test.go @@ -46,8 +46,8 @@ var _ = Describe("Ledger Deletion Data Cleanup", Ordered, func() { Address: "user-0", }) g.Expect(err).To(Succeed()) - g.Expect(account.Volumes).To(HaveKey("USD")) - g.Expect(account.Volumes["USD"].Balance).To(Equal("100")) + g.Expect(account.FindVolume("USD", "")).NotTo(BeNil(), "expected USD entry on account") + g.Expect(account.FindVolume("USD", "").Balance).To(Equal("100")) }).Within(15 * time.Second).WithPolling(500 * time.Millisecond).Should(Succeed()) }) @@ -107,8 +107,8 @@ var _ = Describe("Ledger Deletion Data Cleanup", Ordered, func() { Address: "alice", }) g.Expect(err).To(Succeed()) - g.Expect(account.Volumes).To(HaveKey("USD")) - g.Expect(account.Volumes["USD"].Balance).To(Equal("0")) + g.Expect(account.FindVolume("USD", "")).NotTo(BeNil(), "expected USD entry on account") + g.Expect(account.FindVolume("USD", "").Balance).To(Equal("0")) }).Within(15 * time.Second).WithPolling(500 * time.Millisecond).Should(Succeed()) }) diff --git a/tests/e2e/business/numscript_library_test.go b/tests/e2e/business/numscript_library_test.go index 7f29579ef3..4aee4afa12 100644 --- a/tests/e2e/business/numscript_library_test.go +++ b/tests/e2e/business/numscript_library_test.go @@ -450,8 +450,8 @@ send $amount ( Address: "users:alice", }) g.Expect(err).To(Succeed()) - g.Expect(account.Volumes).To(HaveKey("USD/2")) - g.Expect(account.Volumes["USD/2"].Balance).To(Equal("1000")) + g.Expect(account.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on account") + g.Expect(account.FindVolume("USD/2", "").Balance).To(Equal("1000")) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) diff --git a/tests/e2e/business/numscript_test.go b/tests/e2e/business/numscript_test.go index 1cd5a2374d..545fb09b37 100644 --- a/tests/e2e/business/numscript_test.go +++ b/tests/e2e/business/numscript_test.go @@ -69,8 +69,8 @@ send $amount ( Address: "bank", }) g.Expect(err).To(Succeed()) - g.Expect(account.Volumes).To(HaveKey("USD/2")) - g.Expect(account.Volumes["USD/2"].Balance).To(Equal("1000")) + g.Expect(account.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on account") + g.Expect(account.FindVolume("USD/2", "").Balance).To(Equal("1000")) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) @@ -101,8 +101,8 @@ send $amount ( Address: "users:alice", }) g.Expect(err).To(Succeed()) - g.Expect(account.Volumes).To(HaveKey("EUR/2")) - g.Expect(account.Volumes["EUR/2"].Balance).To(Equal("5000")) + g.Expect(account.FindVolume("EUR/2", "")).NotTo(BeNil(), "expected EUR/2 entry on account") + g.Expect(account.FindVolume("EUR/2", "").Balance).To(Equal("5000")) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) @@ -156,8 +156,8 @@ send $amount ( Address: "taxes:vat", }) g.Expect(err).To(Succeed()) - g.Expect(taxAccount.Volumes).To(HaveKey("USD/2")) - g.Expect(taxAccount.Volumes["USD/2"].Balance).To(Equal("200")) // 20% of 1000 + g.Expect(taxAccount.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on taxAccount") + g.Expect(taxAccount.FindVolume("USD/2", "").Balance).To(Equal("200")) // 20% of 1000 }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) Eventually(func(g Gomega) { @@ -166,8 +166,8 @@ send $amount ( Address: "bank:main", }) g.Expect(err).To(Succeed()) - g.Expect(mainAccount.Volumes).To(HaveKey("USD/2")) - g.Expect(mainAccount.Volumes["USD/2"].Balance).To(Equal("800")) // 80% of 1000 + g.Expect(mainAccount.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on mainAccount") + g.Expect(mainAccount.FindVolume("USD/2", "").Balance).To(Equal("800")) // 80% of 1000 }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) @@ -221,8 +221,8 @@ send $amount ( Address: "users:bob:wallet", }) g.Expect(err).To(Succeed()) - g.Expect(wallet.Volumes).To(HaveKey("USD/2")) - g.Expect(wallet.Volumes["USD/2"].Balance).To(Equal("0")) // Fully drained + g.Expect(wallet.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on wallet") + g.Expect(wallet.FindVolume("USD/2", "").Balance).To(Equal("0")) // Fully drained }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) Eventually(func(g Gomega) { @@ -231,8 +231,8 @@ send $amount ( Address: "users:bob:bank", }) g.Expect(err).To(Succeed()) - g.Expect(bank.Volumes).To(HaveKey("USD/2")) - g.Expect(bank.Volumes["USD/2"].Balance).To(Equal("100")) // 200 - 100 (remainder) + g.Expect(bank.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on bank") + g.Expect(bank.FindVolume("USD/2", "").Balance).To(Equal("100")) // 200 - 100 (remainder) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) Eventually(func(g Gomega) { @@ -241,8 +241,8 @@ send $amount ( Address: "merchants:shop", }) g.Expect(err).To(Succeed()) - g.Expect(shop.Volumes).To(HaveKey("USD/2")) - g.Expect(shop.Volumes["USD/2"].Balance).To(Equal("150")) + g.Expect(shop.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on shop") + g.Expect(shop.FindVolume("USD/2", "").Balance).To(Equal("150")) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) @@ -287,8 +287,8 @@ send $amount ( Address: "users:charlie", }) g.Expect(err).To(Succeed()) - g.Expect(charlie.Volumes).To(HaveKey("EUR/2")) - g.Expect(charlie.Volumes["EUR/2"].Balance).To(Equal("-300")) + g.Expect(charlie.FindVolume("EUR/2", "")).NotTo(BeNil(), "expected EUR/2 entry on charlie") + g.Expect(charlie.FindVolume("EUR/2", "").Balance).To(Equal("-300")) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) @@ -363,8 +363,8 @@ send $amount ( Address: "credit:eve", }) g.Expect(err).To(Succeed()) - g.Expect(creditLine.Volumes).To(HaveKey("USD/2")) - g.Expect(creditLine.Volumes["USD/2"].Balance).To(Equal("-100000")) + g.Expect(creditLine.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on creditLine") + g.Expect(creditLine.FindVolume("USD/2", "").Balance).To(Equal("-100000")) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) }) @@ -475,7 +475,7 @@ send [USD/2 1000] ( Address: "escrow:order-12345", }) Expect(err).To(Succeed()) - Expect(escrow.Volumes["USD/2"].Balance).To(Equal("500")) + Expect(escrow.FindVolume("USD/2", "").Balance).To(Equal("500")) }) It("Should fail with invalid Numscript syntax", func() { @@ -556,8 +556,8 @@ send $amount ( Address: address, }) g.Expect(err).To(Succeed()) - g.Expect(account.Volumes).To(HaveKey("USD/2")) - g.Expect(account.Volumes["USD/2"].Balance).To(Equal(expectedBalance)) + g.Expect(account.FindVolume("USD/2", "")).NotTo(BeNil(), "expected USD/2 entry on account") + g.Expect(account.FindVolume("USD/2", "").Balance).To(Equal(expectedBalance)) }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) } }) diff --git a/tests/e2e/business/prepared_query_rest_shape_test.go b/tests/e2e/business/prepared_query_rest_shape_test.go index bf75cc0b35..b8f7cdaf8c 100644 --- a/tests/e2e/business/prepared_query_rest_shape_test.go +++ b/tests/e2e/business/prepared_query_rest_shape_test.go @@ -157,22 +157,23 @@ var _ = Describe("PreparedQuery REST shape (EN-1465)", Ordered, func() { g.Expect(execErr).To(Succeed()) g.Expect(execResp.StatusCode).To(Equal(http.StatusOK), "unexpected execute status; body=%s", string(execRaw)) - // The ExecutePreparedQueryResponse envelope still leaks the raw - // protobuf oneof shape (Go-cased Result.Cursor) pending EN-1465's - // envelope cleanup; the cursor payload itself is camelCase. Assert on - // the actual wire path so the test tracks reality, not the intended - // envelope. + // EN-1465 replaced the leaked PascalCase proto oneof + // (`Result.Cursor`) with a clean discriminated camelCase envelope: + // the execute handler emits a top-level `{"cursor":{…}}` / + // `{"aggregateResult":{…}}` body (writeJSONResponse, no BaseResponse + // `data` wrapper). The LOGS cursor carries its logs under `logData`. var execBody struct { - Result struct { - Cursor struct { - LogData []json.RawMessage `json:"logData"` - } `json:"Cursor"` - } `json:"Result"` + Cursor struct { + LogData []json.RawMessage `json:"logData"` + } `json:"cursor"` } g.Expect(json.Unmarshal(execRaw, &execBody)).To(Succeed()) + // The camelCase envelope must be used, never the old Go-cased oneof. + g.Expect(string(execRaw)).NotTo(ContainSubstring(`"Result"`), "PascalCase oneof leaked; body=%s", string(execRaw)) + g.Expect(string(execRaw)).NotTo(ContainSubstring(`"Cursor"`), "PascalCase oneof leaked; body=%s", string(execRaw)) // The log payload field is logData (camelCase); it must carry the // ledger's logs rather than the pre-EN-1503 empty cursor. - g.Expect(len(execBody.Result.Cursor.LogData)).To(BeNumerically(">=", 2), "logData empty; body=%s", string(execRaw)) + g.Expect(len(execBody.Cursor.LogData)).To(BeNumerically(">=", 2), "logData empty; body=%s", string(execRaw)) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) }) diff --git a/tests/e2e/business/reference_test.go b/tests/e2e/business/reference_test.go index 91c108b3c5..ed18988982 100644 --- a/tests/e2e/business/reference_test.go +++ b/tests/e2e/business/reference_test.go @@ -258,7 +258,7 @@ var _ = Describe("Transaction Reference Uniqueness", Ordered, func() { Address: account, }) Expect(err).To(Succeed()) - Expect(accountResp.GetVolumes()["USD"].GetInput()).To(Equal("72")) + Expect(accountResp.FindVolume("USD", "").GetInput()).To(Equal("72")) }) It("Should reject a skippable_reasons entry that is not in the operation's whitelist", func() { diff --git a/tests/e2e/business/reversions_test.go b/tests/e2e/business/reversions_test.go index 9f9f01e35f..ebbcef6aba 100644 --- a/tests/e2e/business/reversions_test.go +++ b/tests/e2e/business/reversions_test.go @@ -242,7 +242,7 @@ var _ = Describe("Reversions", Ordered, func() { Address: "account-1", }) Expect(err).To(Succeed()) - Expect(account1.Volumes["USD"].Balance).To(Equal("100")) + Expect(account1.FindVolume("USD", "").Balance).To(Equal("100")) // Revert the transaction log := createResp.Logs[0] @@ -258,7 +258,7 @@ var _ = Describe("Reversions", Ordered, func() { Address: "account-1", }) Expect(err).To(Succeed()) - Expect(account1After.Volumes["USD"].Balance).To(Equal("0")) + Expect(account1After.FindVolume("USD", "").Balance).To(Equal("0")) }) It("Should restore balances for multi-posting transaction", func() { @@ -275,14 +275,14 @@ var _ = Describe("Reversions", Ordered, func() { Address: "account-a", }) Expect(err).To(Succeed()) - Expect(accountA.Volumes["USD"].Balance).To(Equal("100")) + Expect(accountA.FindVolume("USD", "").Balance).To(Equal("100")) accountB, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ Ledger: ledgerName, Address: "account-b", }) Expect(err).To(Succeed()) - Expect(accountB.Volumes["USD"].Balance).To(Equal("200")) + Expect(accountB.FindVolume("USD", "").Balance).To(Equal("200")) // Revert the transaction log := createResp.Logs[0] @@ -298,14 +298,14 @@ var _ = Describe("Reversions", Ordered, func() { Address: "account-a", }) Expect(err).To(Succeed()) - Expect(accountAAfter.Volumes["USD"].Balance).To(Equal("0")) + Expect(accountAAfter.FindVolume("USD", "").Balance).To(Equal("0")) accountBAfter, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ Ledger: ledgerName, Address: "account-b", }) Expect(err).To(Succeed()) - Expect(accountBAfter.Volumes["USD"].Balance).To(Equal("0")) + Expect(accountBAfter.FindVolume("USD", "").Balance).To(Equal("0")) }) It("Should correctly track volumes after revert", func() { @@ -329,9 +329,9 @@ var _ = Describe("Reversions", Ordered, func() { Address: "volume-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Input).To(Equal("100")) - Expect(account.Volumes["USD"].Output).To(Equal("100")) - Expect(account.Volumes["USD"].Balance).To(Equal("0")) + Expect(account.FindVolume("USD", "").Input).To(Equal("100")) + Expect(account.FindVolume("USD", "").Output).To(Equal("100")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("0")) }) }) @@ -467,7 +467,7 @@ var _ = Describe("Reversions", Ordered, func() { Address: "account-1", }) Expect(err).To(Succeed()) - Expect(account1.Volumes["USD"].Balance).To(Equal("-100")) + Expect(account1.FindVolume("USD", "").Balance).To(Equal("-100")) }) }) @@ -518,8 +518,8 @@ var _ = Describe("Reversions", Ordered, func() { pcv := revertedTx.PostCommitVolumes.VolumesByAccount // After revert: ev-rv-expand sent 100 back to world -> input=100, output=100 Expect(pcv).To(HaveKey("ev-rv-expand")) - Expect(pcv["ev-rv-expand"].Volumes["USD"].Input).To(Equal("100")) - Expect(pcv["ev-rv-expand"].Volumes["USD"].Output).To(Equal("100")) + Expect(pcv["ev-rv-expand"].FindVolume("USD", "").Input).To(Equal("100")) + Expect(pcv["ev-rv-expand"].FindVolume("USD", "").Output).To(Equal("100")) Expect(pcv).To(HaveKey("world")) }) @@ -549,8 +549,8 @@ var _ = Describe("Reversions", Ordered, func() { pcv := revertedTx.PostCommitVolumes.VolumesByAccount // ev-rv-force: input=100 (original), output=200 (100 spent + 100 reverted) Expect(pcv).To(HaveKey("ev-rv-force")) - Expect(pcv["ev-rv-force"].Volumes["USD"].Input).To(Equal("100")) - Expect(pcv["ev-rv-force"].Volumes["USD"].Output).To(Equal("200")) + Expect(pcv["ev-rv-force"].FindVolume("USD", "").Input).To(Equal("100")) + Expect(pcv["ev-rv-force"].FindVolume("USD", "").Output).To(Equal("200")) }) }) }) diff --git a/tests/e2e/business/transactions_test.go b/tests/e2e/business/transactions_test.go index bfb2a6b0f9..25e8fafb92 100644 --- a/tests/e2e/business/transactions_test.go +++ b/tests/e2e/business/transactions_test.go @@ -142,7 +142,7 @@ var _ = Describe("Transactions", Ordered, func() { }) Expect(err).To(Succeed()) Expect(account.Address).To(Equal("account-with-meta")) - Expect(account.Volumes["USD"].Balance).To(Equal("100")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("100")) }) It("Should create multiple transactions sequentially", func() { @@ -172,14 +172,14 @@ var _ = Describe("Transactions", Ordered, func() { Address: "seq-account-1", }) Expect(err).To(Succeed()) - Expect(account1.Volumes["USD"].Balance).To(Equal("50")) // 100 - 50 + Expect(account1.FindVolume("USD", "").Balance).To(Equal("50")) // 100 - 50 account2, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ Ledger: ledgerName, Address: "seq-account-2", }) Expect(err).To(Succeed()) - Expect(account2.Volumes["USD"].Balance).To(Equal("250")) // 200 + 50 + Expect(account2.FindVolume("USD", "").Balance).To(Equal("250")) // 200 + 50 }) It("Should create a transaction with multiple postings", func() { @@ -204,21 +204,21 @@ var _ = Describe("Transactions", Ordered, func() { Address: "account-a", }) Expect(err).To(Succeed()) - Expect(accountA.Volumes["USD"].Balance).To(Equal("100")) + Expect(accountA.FindVolume("USD", "").Balance).To(Equal("100")) accountB, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ Ledger: ledgerName, Address: "account-b", }) Expect(err).To(Succeed()) - Expect(accountB.Volumes["USD"].Balance).To(Equal("200")) + Expect(accountB.FindVolume("USD", "").Balance).To(Equal("200")) accountC, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ Ledger: ledgerName, Address: "account-c", }) Expect(err).To(Succeed()) - Expect(accountC.Volumes["USD"].Balance).To(Equal("300")) + Expect(accountC.FindVolume("USD", "").Balance).To(Equal("300")) }) It("Should create a transaction with multiple assets", func() { @@ -238,9 +238,9 @@ var _ = Describe("Transactions", Ordered, func() { }) Expect(err).To(Succeed()) Expect(account.Volumes).To(HaveLen(3)) - Expect(account.Volumes["USD"].Balance).To(Equal("100")) - Expect(account.Volumes["EUR"].Balance).To(Equal("50")) - Expect(account.Volumes["JPY"].Balance).To(Equal("1000")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("100")) + Expect(account.FindVolume("EUR", "").Balance).To(Equal("50")) + Expect(account.FindVolume("JPY", "").Balance).To(Equal("1000")) }) It("Should create multiple transactions in bulk", func() { @@ -281,7 +281,7 @@ var _ = Describe("Transactions", Ordered, func() { }) Expect(err).To(Succeed()) Expect(account.Address).To(Equal("implicit-account")) - Expect(account.Volumes["USD"].Balance).To(Equal("100")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("100")) }) It("Should handle large amounts correctly", func() { @@ -302,7 +302,7 @@ var _ = Describe("Transactions", Ordered, func() { Address: "large-amount-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Balance).To(Equal("99999999999999999999999999999")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("99999999999999999999999999999")) }) }) @@ -369,7 +369,7 @@ var _ = Describe("Transactions", Ordered, func() { Address: "recipient", }) Expect(err).To(Succeed()) - Expect(recipient.Volumes["USD"].Balance).To(Equal("1000000")) + Expect(recipient.FindVolume("USD", "").Balance).To(Equal("1000000")) // World's balance should be negative world, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ @@ -377,7 +377,7 @@ var _ = Describe("Transactions", Ordered, func() { Address: "world", }) Expect(err).To(Succeed()) - Expect(world.Volumes["USD"].Balance).To(HavePrefix("-")) + Expect(world.FindVolume("USD", "").Balance).To(HavePrefix("-")) }) }) @@ -453,9 +453,9 @@ var _ = Describe("Transactions", Ordered, func() { Address: "volume-account", }) Expect(err).To(Succeed()) - Expect(account.Volumes["USD"].Input).To(Equal("1000")) - Expect(account.Volumes["USD"].Output).To(Equal("300")) - Expect(account.Volumes["USD"].Balance).To(Equal("700")) + Expect(account.FindVolume("USD", "").Input).To(Equal("1000")) + Expect(account.FindVolume("USD", "").Output).To(Equal("300")) + Expect(account.FindVolume("USD", "").Balance).To(Equal("700")) }) It("Should handle circular transactions correctly", func() { @@ -486,9 +486,9 @@ var _ = Describe("Transactions", Ordered, func() { Address: "cycle-a", }) Expect(err).To(Succeed()) - Expect(accountA.Volumes["USD"].Input).To(Equal("200")) // from world + cycle-c - Expect(accountA.Volumes["USD"].Output).To(Equal("100")) // to cycle-b - Expect(accountA.Volumes["USD"].Balance).To(Equal("100")) + Expect(accountA.FindVolume("USD", "").Input).To(Equal("200")) // from world + cycle-c + Expect(accountA.FindVolume("USD", "").Output).To(Equal("100")) // to cycle-b + Expect(accountA.FindVolume("USD", "").Balance).To(Equal("100")) }) }) @@ -684,12 +684,13 @@ var _ = Describe("Transactions", Ordered, func() { Expect(pcv).To(HaveKey("ev-simple")) // world is shared across tests in this Ordered context, so only check presence - Expect(pcv["world"].Volumes).To(HaveKey("USD")) + Expect(pcv["world"].FindVolume("USD", "")).NotTo(BeNil(), "expected USD entry on world") // ev-simple is fresh — exact values are predictable - Expect(pcv["ev-simple"].Volumes).To(HaveKey("USD")) - Expect(pcv["ev-simple"].Volumes["USD"].Input).To(Equal("100")) - Expect(pcv["ev-simple"].Volumes["USD"].Output).To(Equal("0")) + evSimple := pcv["ev-simple"].FindVolume("USD", "") + Expect(evSimple).NotTo(BeNil(), "expected USD entry on ev-simple") + Expect(evSimple.GetInput()).To(Equal("100")) + Expect(evSimple.GetOutput()).To(Equal("0")) }) It("Should include correct volumes for multiple postings", func() { @@ -704,10 +705,10 @@ var _ = Describe("Transactions", Ordered, func() { Expect(pcv).To(HaveKey("ev-multi-a")) Expect(pcv).To(HaveKey("ev-multi-b")) - Expect(pcv["ev-multi-a"].Volumes["USD"].Input).To(Equal("100")) - Expect(pcv["ev-multi-a"].Volumes["USD"].Output).To(Equal("0")) - Expect(pcv["ev-multi-b"].Volumes["USD"].Input).To(Equal("200")) - Expect(pcv["ev-multi-b"].Volumes["USD"].Output).To(Equal("0")) + Expect(pcv["ev-multi-a"].FindVolume("USD", "").Input).To(Equal("100")) + Expect(pcv["ev-multi-a"].FindVolume("USD", "").Output).To(Equal("0")) + Expect(pcv["ev-multi-b"].FindVolume("USD", "").Input).To(Equal("200")) + Expect(pcv["ev-multi-b"].FindVolume("USD", "").Output).To(Equal("0")) }) It("Should include correct volumes for multiple assets", func() { @@ -720,13 +721,15 @@ var _ = Describe("Transactions", Ordered, func() { pcv := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount Expect(pcv).To(HaveKey("ev-multi-asset")) - vols := pcv["ev-multi-asset"].Volumes - Expect(vols).To(HaveKey("USD")) - Expect(vols).To(HaveKey("EUR")) - Expect(vols["USD"].Input).To(Equal("100")) - Expect(vols["USD"].Output).To(Equal("0")) - Expect(vols["EUR"].Input).To(Equal("50")) - Expect(vols["EUR"].Output).To(Equal("0")) + vba := pcv["ev-multi-asset"] + usd := vba.FindVolume("USD", "") + eur := vba.FindVolume("EUR", "") + Expect(usd).NotTo(BeNil(), "expected USD entry on ev-multi-asset") + Expect(eur).NotTo(BeNil(), "expected EUR entry on ev-multi-asset") + Expect(usd.GetInput()).To(Equal("100")) + Expect(usd.GetOutput()).To(Equal("0")) + Expect(eur.GetInput()).To(Equal("50")) + Expect(eur.GetOutput()).To(Equal("0")) }) It("Should reflect cumulative volumes across sequential transactions", func() { @@ -736,8 +739,8 @@ var _ = Describe("Transactions", Ordered, func() { Expect(err).To(Succeed()) pcv1 := resp1.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount - Expect(pcv1["ev-cumul"].Volumes["USD"].Input).To(Equal("500")) - Expect(pcv1["ev-cumul"].Volumes["USD"].Output).To(Equal("0")) + Expect(pcv1["ev-cumul"].FindVolume("USD", "").Input).To(Equal("500")) + Expect(pcv1["ev-cumul"].FindVolume("USD", "").Output).To(Equal("0")) resp2, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.WithExpandVolumes(actions.CreateTransactionAction(ledgerName, []*commonpb.Posting{ actions.NewPosting("ev-cumul", "ev-cumul-dest", big.NewInt(200), "USD"), @@ -745,10 +748,10 @@ var _ = Describe("Transactions", Ordered, func() { Expect(err).To(Succeed()) pcv2 := resp2.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount - Expect(pcv2["ev-cumul"].Volumes["USD"].Input).To(Equal("500")) - Expect(pcv2["ev-cumul"].Volumes["USD"].Output).To(Equal("200")) - Expect(pcv2["ev-cumul-dest"].Volumes["USD"].Input).To(Equal("200")) - Expect(pcv2["ev-cumul-dest"].Volumes["USD"].Output).To(Equal("0")) + Expect(pcv2["ev-cumul"].FindVolume("USD", "").Input).To(Equal("500")) + Expect(pcv2["ev-cumul"].FindVolume("USD", "").Output).To(Equal("200")) + Expect(pcv2["ev-cumul-dest"].FindVolume("USD", "").Input).To(Equal("200")) + Expect(pcv2["ev-cumul-dest"].FindVolume("USD", "").Output).To(Equal("0")) }) It("Should work with force flag and expandVolumes", func() { @@ -764,10 +767,10 @@ var _ = Describe("Transactions", Ordered, func() { Expect(pcv).To(HaveKey("ev-force-src")) Expect(pcv).To(HaveKey("ev-force-dst")) - Expect(pcv["ev-force-src"].Volumes["USD"].Input).To(Equal("0")) - Expect(pcv["ev-force-src"].Volumes["USD"].Output).To(Equal("100")) - Expect(pcv["ev-force-dst"].Volumes["USD"].Input).To(Equal("100")) - Expect(pcv["ev-force-dst"].Volumes["USD"].Output).To(Equal("0")) + Expect(pcv["ev-force-src"].FindVolume("USD", "").Input).To(Equal("0")) + Expect(pcv["ev-force-src"].FindVolume("USD", "").Output).To(Equal("100")) + Expect(pcv["ev-force-dst"].FindVolume("USD", "").Input).To(Equal("100")) + Expect(pcv["ev-force-dst"].FindVolume("USD", "").Output).To(Equal("0")) }) It("Should include postCommitVolumes with Numscript transaction", func() { @@ -787,8 +790,8 @@ var _ = Describe("Transactions", Ordered, func() { pcv := createdTx.PostCommitVolumes.VolumesByAccount Expect(pcv).To(HaveKey("world")) Expect(pcv).To(HaveKey("user:001")) - Expect(pcv["user:001"].Volumes["USD/2"].Input).To(Equal("100")) - Expect(pcv["user:001"].Volumes["USD/2"].Output).To(Equal("0")) + Expect(pcv["user:001"].FindVolume("USD/2", "").Input).To(Equal("100")) + Expect(pcv["user:001"].FindVolume("USD/2", "").Output).To(Equal("0")) }) It("Should include postCommitVolumes for each transaction in a bulk request", func() { @@ -802,10 +805,10 @@ var _ = Describe("Transactions", Ordered, func() { Expect(resp.Logs).To(HaveLen(2)) pcv1 := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount - Expect(pcv1["ev-bulk-a"].Volumes["USD"].Input).To(Equal("100")) + Expect(pcv1["ev-bulk-a"].FindVolume("USD", "").Input).To(Equal("100")) pcv2 := resp.Logs[1].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount - Expect(pcv2["ev-bulk-b"].Volumes["USD"].Input).To(Equal("200")) + Expect(pcv2["ev-bulk-b"].FindVolume("USD", "").Input).To(Equal("200")) }) It("Should allow mixing expandVolumes=true and expandVolumes=false in bulk", func() { diff --git a/tests/e2e/business/transient_accounts_test.go b/tests/e2e/business/transient_accounts_test.go index 946451776d..64e591e219 100644 --- a/tests/e2e/business/transient_accounts_test.go +++ b/tests/e2e/business/transient_accounts_test.go @@ -82,8 +82,8 @@ var _ = Describe("TransientAccounts", Ordered, func() { }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue(), "expected USD volumes on wallet:main") + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil(), "expected USD volumes on wallet:main") g.Expect(usdVol.GetInput()).To(Equal("100")) g.Expect(usdVol.GetBalance()).To(Equal("100")) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) @@ -229,8 +229,8 @@ var _ = Describe("TransientAccounts", Ordered, func() { Address: "staging:a", }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue()) + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil()) g.Expect(usdVol.GetInput()).To(Equal("100")) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) @@ -291,8 +291,8 @@ var _ = Describe("TransientAccounts", Ordered, func() { Address: "wallet:b", }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue()) + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil()) g.Expect(usdVol.GetInput()).To(Equal("100")) g.Expect(usdVol.GetBalance()).To(Equal("100")) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) @@ -391,8 +391,8 @@ var _ = Describe("TransientAccounts", Ordered, func() { Address: "wallet:y", }) g.Expect(err).To(Succeed()) - usdVol, ok := account.GetVolumes()["USD"] - g.Expect(ok).To(BeTrue()) + usdVol := account.FindVolume("USD", "") + g.Expect(usdVol).NotTo(BeNil()) g.Expect(usdVol.GetInput()).To(Equal("200")) g.Expect(usdVol.GetBalance()).To(Equal("200")) }).Within(5 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) @@ -441,20 +441,20 @@ var _ = Describe("TransientAccounts", Ordered, func() { // staging:reuse should read {50, 0} — fresh, not cumulative {150, 100}. pcv1 := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount Expect(pcv1).To(HaveKey("staging:reuse")) - Expect(pcv1["staging:reuse"].Volumes["USD"].Input).To(Equal("50"), + Expect(pcv1["staging:reuse"].FindVolume("USD", "").Input).To(Equal("50"), "transient input should reflect this batch only, not accumulate across batches") - Expect(pcv1["staging:reuse"].Volumes["USD"].Output).To(Equal("0")) + Expect(pcv1["staging:reuse"].FindVolume("USD", "").Output).To(Equal("0")) // Second transaction's PCV: staging:reuse → wallet:b 50. // staging:reuse now {50, 50} — the per-batch zero-balance — not {150, 150}. pcv2 := resp.Logs[1].Payload.GetApply().Log.Data.GetCreatedTransaction().PostCommitVolumes.VolumesByAccount Expect(pcv2).To(HaveKey("staging:reuse")) - Expect(pcv2["staging:reuse"].Volumes["USD"].Input).To(Equal("50")) - Expect(pcv2["staging:reuse"].Volumes["USD"].Output).To(Equal("50")) + Expect(pcv2["staging:reuse"].FindVolume("USD", "").Input).To(Equal("50")) + Expect(pcv2["staging:reuse"].FindVolume("USD", "").Output).To(Equal("50")) // And the wallet sees its fresh +50. - Expect(pcv2["wallet:b"].Volumes["USD"].Input).To(Equal("50")) - Expect(pcv2["wallet:b"].Volumes["USD"].Output).To(Equal("0")) + Expect(pcv2["wallet:b"].FindVolume("USD", "").Input).To(Equal("50")) + Expect(pcv2["wallet:b"].FindVolume("USD", "").Output).To(Equal("0")) }) }) }) diff --git a/tests/e2e/cluster/bloom_config_test.go b/tests/e2e/cluster/bloom_config_test.go index 4ecbe440bd..df3079df34 100644 --- a/tests/e2e/cluster/bloom_config_test.go +++ b/tests/e2e/cluster/bloom_config_test.go @@ -210,7 +210,9 @@ var _ = Describe("Bloom filter config change preserves data", Ordered, func() { Eventually(func(g Gomega) { account, err := actions.GetAccount(ctx, srv.Client, "post-bloom-change", "user:1") g.Expect(err).To(Succeed()) - g.Expect(account.Volumes["EUR"].Input).To(Equal("999")) + eurVol := account.FindVolume("EUR", "") + g.Expect(eurVol).NotTo(BeNil(), "expected EUR volumes on user:1") + g.Expect(eurVol.GetInput()).To(Equal("999")) }). WithTimeout(30 * time.Second). WithPolling(500 * time.Millisecond). diff --git a/tests/e2e/cluster/bootstrap_test.go b/tests/e2e/cluster/bootstrap_test.go index 88a9372657..343a149d4c 100644 --- a/tests/e2e/cluster/bootstrap_test.go +++ b/tests/e2e/cluster/bootstrap_test.go @@ -345,12 +345,12 @@ var _ = Describe("Bootstrap from backup", Ordered, func() { It("should have the correct account balances", func() { aliceResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "alice"}) Expect(err).To(Succeed()) - Expect(aliceResp.Volumes["USD"].Input).To(Equal("3000")) + Expect(aliceResp.FindVolume("USD", "").Input).To(Equal("3000")) bankResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "bank"}) Expect(err).To(Succeed()) - Expect(bankResp.Volumes["USD"].Input).To(Equal("10000")) - Expect(bankResp.Volumes["USD"].Output).To(Equal("5000")) + Expect(bankResp.FindVolume("USD", "").Input).To(Equal("10000")) + Expect(bankResp.FindVolume("USD", "").Output).To(Equal("5000")) }) It("should have the correct account metadata", func() { @@ -362,7 +362,7 @@ var _ = Describe("Bootstrap from backup", Ordered, func() { It("should have the data added after the first backup (via second full backup)", func() { eveResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "eve"}) Expect(err).To(Succeed()) - Expect(eveResp.Volumes["USD"].Input).To(Equal("500")) + Expect(eveResp.FindVolume("USD", "").Input).To(Equal("500")) }) It("should accept new transactions after bootstrap", func() { @@ -373,7 +373,7 @@ var _ = Describe("Bootstrap from backup", Ordered, func() { charlieResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "charlie"}) Expect(err).To(Succeed()) - Expect(charlieResp.Volumes["USD"].Input).To(Equal("1000")) + Expect(charlieResp.FindVolume("USD", "").Input).To(Equal("1000")) }) }) }) diff --git a/tests/e2e/cluster/cache_divergence_test.go b/tests/e2e/cluster/cache_divergence_test.go index f9ffccb0cf..4e47465103 100644 --- a/tests/e2e/cluster/cache_divergence_test.go +++ b/tests/e2e/cluster/cache_divergence_test.go @@ -189,8 +189,12 @@ func verifyVolumesConsistent(ctx context.Context, servers []*testutil.ServiceWit } for _, acct := range accounts { - for asset, vol := range acct.GetVolumes() { - key := fmt.Sprintf("%s/%s", acct.GetAddress(), asset) + for _, entry := range acct.GetVolumes() { + vol := entry.GetVolumes() + // Key on (account, asset, color) so colored buckets stay + // distinct in the divergence snapshot. Empty color is the + // uncolored bucket. + key := fmt.Sprintf("%s/%s/%s", acct.GetAddress(), entry.GetAsset(), entry.GetColor()) snap.volumes[key] = fmt.Sprintf("%s:%s", vol.GetInput(), vol.GetOutput()) } } diff --git a/tests/e2e/cluster/query_checkpoint_test.go b/tests/e2e/cluster/query_checkpoint_test.go index 484729e928..c71ff70ac0 100644 --- a/tests/e2e/cluster/query_checkpoint_test.go +++ b/tests/e2e/cluster/query_checkpoint_test.go @@ -129,7 +129,7 @@ var _ = Describe("Query Checkpoints", func() { // bob was funded (500 EUR) AFTER the checkpoint. liveBob, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "bob"}) Expect(err).To(Succeed()) - Expect(liveBob.GetVolumes()).To(HaveKey("EUR"), "live store has the post-checkpoint balance") + Expect(liveBob.FindVolume("EUR", "")).NotTo(BeNil(), "live store has the post-checkpoint balance") cpBob, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{ Ledger: ledgerName, @@ -137,7 +137,7 @@ var _ = Describe("Query Checkpoints", func() { CheckpointId: checkpointID, }) Expect(err).To(Succeed()) - Expect(cpBob.GetVolumes()).NotTo(HaveKey("EUR"), + Expect(cpBob.FindVolume("EUR", "")).To(BeNil(), "checkpoint predates bob; reading it must not return live data") }) @@ -347,8 +347,8 @@ var _ = Describe("Query Checkpoints", func() { }) Expect(err).To(Succeed()) - vols, ok := resp.GetVolumes()[asset] - Expect(ok).To(BeTrue(), "expected %s volumes at checkpoint %d", asset, cp) + vols := resp.FindVolume(asset, "") + Expect(vols).NotTo(BeNil(), "expected %s volumes at checkpoint %d", asset, cp) Expect(vols.GetBalance()).To(Equal(expected), "balance at checkpoint %d should be frozen at %s", cp, expected) } @@ -372,7 +372,7 @@ var _ = Describe("Query Checkpoints", func() { }) Expect(err).To(Succeed()) - Expect(resp.GetVolumes()[asset].GetBalance()).To(Equal("400")) + Expect(resp.FindVolume(asset, "").GetBalance()).To(Equal("400")) }) }) diff --git a/tests/e2e/cluster/restore_stale_cache_test.go b/tests/e2e/cluster/restore_stale_cache_test.go index c332163143..b1c27742dd 100644 --- a/tests/e2e/cluster/restore_stale_cache_test.go +++ b/tests/e2e/cluster/restore_stale_cache_test.go @@ -323,8 +323,8 @@ var _ = Describe("Restore stale cache", Ordered, func() { // isolates any failure below to the cache, not the rebuild. resp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "mallory"}) Expect(err).To(Succeed()) - Expect(resp.Volumes["USD"].Input).To(Equal("1000")) - Expect(resp.Volumes["USD"].Output).To(Equal("1000")) + Expect(resp.FindVolume("USD", "").GetInput()).To(Equal("1000")) + Expect(resp.FindVolume("USD", "").GetOutput()).To(Equal("1000")) }) It("should apply against the drained volumes, not the checkpoint-era cache entry", func() { @@ -335,8 +335,8 @@ var _ = Describe("Restore stale cache", Ordered, func() { resp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "mallory"}) Expect(err).To(Succeed()) - Expect(resp.Volumes["USD"].Input).To(Equal("1500")) - Expect(resp.Volumes["USD"].Output).To(Equal("1000"), + Expect(resp.FindVolume("USD", "").GetInput()).To(Equal("1500")) + Expect(resp.FindVolume("USD", "").GetOutput()).To(Equal("1000"), "the FSM read mallory's VolumePair from the restored 0xFF cache (checkpoint-era input=1000/output=0) instead of the delta-rebuilt 0xF1 value, clobbering the drain") }) }) diff --git a/tests/e2e/cluster/restore_test.go b/tests/e2e/cluster/restore_test.go index a21d7133c3..4709ff4749 100644 --- a/tests/e2e/cluster/restore_test.go +++ b/tests/e2e/cluster/restore_test.go @@ -597,16 +597,16 @@ var _ = Describe("Restore", Ordered, func() { It("should have the correct account balances on ledger 1", func() { aliceResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "alice"}) Expect(err).To(Succeed()) - Expect(aliceResp.Volumes["USD"].Input).To(Equal("3000")) + Expect(aliceResp.FindVolume("USD", "").Input).To(Equal("3000")) bobResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "bob"}) Expect(err).To(Succeed()) - Expect(bobResp.Volumes["USD"].Input).To(Equal("2000")) + Expect(bobResp.FindVolume("USD", "").Input).To(Equal("2000")) bankResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "bank"}) Expect(err).To(Succeed()) - Expect(bankResp.Volumes["USD"].Input).To(Equal("10000")) - Expect(bankResp.Volumes["USD"].Output).To(Equal("5000")) + Expect(bankResp.FindVolume("USD", "").Input).To(Equal("10000")) + Expect(bankResp.FindVolume("USD", "").Output).To(Equal("5000")) }) It("should have the correct account metadata", func() { @@ -633,7 +633,7 @@ var _ = Describe("Restore", Ordered, func() { It("should have the correct data on ledger 2", func() { treasuryResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledger2, Address: "treasury"}) Expect(err).To(Succeed()) - Expect(treasuryResp.Volumes["EUR"].Input).To(Equal("50000")) + Expect(treasuryResp.FindVolume("EUR", "").Input).To(Equal("50000")) }) It("should have post-checkpoint data restored from export segments", func() { @@ -641,7 +641,7 @@ var _ = Describe("Restore", Ordered, func() { // only be present if the restore applied the incremental exports. daveResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "dave"}) Expect(err).To(Succeed()) - Expect(daveResp.Volumes["USD"].Input).To(Equal("1500"), + Expect(daveResp.FindVolume("USD", "").Input).To(Equal("1500"), "transaction written after the checkpoint must be restored from export segments") }) @@ -651,7 +651,7 @@ var _ = Describe("Restore", Ordered, func() { // BOTH incrementals — the full + multiple incrementals chain. erinResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "erin"}) Expect(err).To(Succeed()) - Expect(erinResp.Volumes["USD"].Input).To(Equal("2500"), + Expect(erinResp.FindVolume("USD", "").Input).To(Equal("2500"), "transaction written in the second incremental must be restored from the full + multi-incremental chain") }) @@ -671,7 +671,7 @@ var _ = Describe("Restore", Ordered, func() { daveResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "dave"}) Expect(err).To(Succeed()) - Expect(daveResp.Volumes["USD"].Input).To(Equal("2000"), + Expect(daveResp.FindVolume("USD", "").Input).To(Equal("2000"), "apply must see dave's restored balance via the cache; a cache/bloom-blind apply yields 500") }) @@ -690,8 +690,8 @@ var _ = Describe("Restore", Ordered, func() { eveResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "eve"}) Expect(err).To(Succeed()) - Expect(eveResp.Volumes["USD"].Input).To(Equal("1500")) - Expect(eveResp.Volumes["USD"].Output).To(Equal("1000"), + Expect(eveResp.FindVolume("USD", "").GetInput()).To(Equal("1500")) + Expect(eveResp.FindVolume("USD", "").GetOutput()).To(Equal("1000"), "apply must see eve's post-checkpoint drain via a cache-aware restore; a cache-blind restore clobbers output to 0") }) @@ -727,7 +727,7 @@ var _ = Describe("Restore", Ordered, func() { It("should have the delta ledger's data restored from export segments", func() { founderResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: deltaLedger, Address: "founder"}) Expect(err).To(Succeed()) - Expect(founderResp.Volumes["USD"].Input).To(Equal("9000")) + Expect(founderResp.FindVolume("USD", "").GetInput()).To(Equal("9000")) }) It("should reconstruct ledger stats for a ledger created after the checkpoint", func() { @@ -774,7 +774,7 @@ var _ = Describe("Restore", Ordered, func() { employeeResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: deltaLedger, Address: "employee"}) Expect(err).To(Succeed()) - Expect(employeeResp.Volumes["USD"].Input).To(Equal("1200")) + Expect(employeeResp.FindVolume("USD", "").GetInput()).To(Equal("1200")) }) It("should accept new transactions after restore", func() { @@ -785,7 +785,7 @@ var _ = Describe("Restore", Ordered, func() { charlieResp, err := client.GetAccount(ctx, &servicepb.GetAccountRequest{Ledger: ledgerName, Address: "charlie"}) Expect(err).To(Succeed()) - Expect(charlieResp.Volumes["USD"].Input).To(Equal("1000")) + Expect(charlieResp.FindVolume("USD", "").Input).To(Equal("1000")) }) }) }) diff --git a/tests/e2e/cluster/rolling_config_test.go b/tests/e2e/cluster/rolling_config_test.go index 42242ab988..3200bf9c7f 100644 --- a/tests/e2e/cluster/rolling_config_test.go +++ b/tests/e2e/cluster/rolling_config_test.go @@ -108,7 +108,7 @@ func expectVolume(ctx context.Context, client servicepb.BucketServiceClient, led Eventually(func(g Gomega) { account, err := actions.GetAccount(ctx, client, ledger, "bank") g.Expect(err).To(Succeed()) - g.Expect(account.Volumes["USD"].Input).To(Equal(expectedInput)) + g.Expect(account.FindVolume("USD", "").Input).To(Equal(expectedInput)) }).WithTimeout(30 * time.Second).WithPolling(500 * time.Millisecond).Should(Succeed()) } @@ -118,7 +118,7 @@ func expectVolumeAllNodes(ctx context.Context, servers []*testutil.ServiceWithCl Eventually(func(g Gomega) { account, err := actions.GetAccount(ctx, srv.Client, ledger, "bank") g.Expect(err).To(Succeed()) - g.Expect(account.Volumes["USD"].Input).To(Equal(expectedInput)) + g.Expect(account.FindVolume("USD", "").Input).To(Equal(expectedInput)) }). WithTimeout(30*time.Second). WithPolling(500*time.Millisecond). diff --git a/tests/scenarios/scenariotest/scenariotest.go b/tests/scenarios/scenariotest/scenariotest.go index af24200f0f..5edb146798 100644 --- a/tests/scenarios/scenariotest/scenariotest.go +++ b/tests/scenarios/scenariotest/scenariotest.go @@ -210,68 +210,87 @@ func CloseChapterAndWait(t *testing.T, ctx context.Context, client servicepb.Buc // Invariant checks (Antithesis-ready) // --------------------------------------------------------------------------- -// CheckPositiveBalance verifies that an account has a strictly positive balance for a given asset. +// CheckPositiveBalance verifies that the uncolored balance of an account +// for a given asset is strictly positive. func CheckPositiveBalance(t *testing.T, ctx context.Context, client servicepb.BucketServiceClient, ledgerName, address, asset string) { t.Helper() acct, err := actions.GetAccount(ctx, client, ledgerName, address) require.NoError(t, err, "failed to get account %s", address) - vol, ok := acct.Volumes[asset] - require.True(t, ok, "account %s has no volumes for asset %s", address, asset) + vol := acct.FindVolume(asset, "") + require.NotNil(t, vol, "account %s has no volumes for asset %s (uncolored)", address, asset) - balance, ok := new(big.Int).SetString(vol.Balance, 10) - require.True(t, ok, "invalid balance %q for account %s asset %s", vol.Balance, address, asset) + balance, ok := new(big.Int).SetString(vol.GetBalance(), 10) + require.True(t, ok, "invalid balance %q for account %s asset %s", vol.GetBalance(), address, asset) require.True(t, balance.Sign() > 0, "account %s asset %s: expected positive balance, got %s", address, asset, balance.String()) } -// CheckDoubleEntryBalance verifies that for every asset in the ledger, -// the sum of all account balances equals zero (double-entry invariant). +// CheckDoubleEntryBalance verifies that for every (asset, color) tuple, the +// sum of all account balances equals zero (double-entry invariant). Each +// (asset, color) bucket is its own segregated double-entry universe. func CheckDoubleEntryBalance(t *testing.T, ctx context.Context, client servicepb.BucketServiceClient, ledgerName string) { t.Helper() accounts, err := actions.ListAllAccounts(ctx, client, ledgerName) require.NoError(t, err, "failed to list accounts for double-entry check") - sums := make(map[string]*big.Int) // asset -> sum of balances + type bucket struct{ asset, color string } + sums := make(map[bucket]*big.Int) for _, acct := range accounts { - for asset, vol := range acct.Volumes { - balance, ok := new(big.Int).SetString(vol.Balance, 10) - require.True(t, ok, "invalid balance %q for account %s asset %s", vol.Balance, acct.Address, asset) - - if sums[asset] == nil { - sums[asset] = new(big.Int) + for _, entry := range acct.GetVolumes() { + vol := entry.GetVolumes() + balance, ok := new(big.Int).SetString(vol.GetBalance(), 10) + require.True(t, ok, "invalid balance %q for account %s asset %s color %q", + vol.GetBalance(), acct.GetAddress(), entry.GetAsset(), entry.GetColor()) + + k := bucket{asset: entry.GetAsset(), color: entry.GetColor()} + if sums[k] == nil { + sums[k] = new(big.Int) } - sums[asset].Add(sums[asset], balance) + sums[k].Add(sums[k], balance) } } - for asset, sum := range sums { + for k, sum := range sums { require.Equal(t, 0, sum.Sign(), - "double-entry violated for asset %s: sum of balances = %s (expected 0)", asset, sum.String()) + "double-entry violated for asset %s color %q: sum of balances = %s (expected 0)", + k.asset, k.color, sum.String()) } } -// CheckAccountBalance verifies that a specific account has the expected balance for a given asset. +// CheckAccountBalance verifies the uncolored balance of an account for a +// given asset matches the expected amount. For colored buckets, use +// CheckColoredAccountBalance. func CheckAccountBalance(t *testing.T, ctx context.Context, client servicepb.BucketServiceClient, ledgerName, address, asset string, expected *big.Int) { t.Helper() + CheckColoredAccountBalance(t, ctx, client, ledgerName, address, asset, "", expected) +} + +// CheckColoredAccountBalance verifies the balance for a specific +// (account, asset, color) bucket. Color "" is the uncolored bucket. +func CheckColoredAccountBalance(t *testing.T, ctx context.Context, client servicepb.BucketServiceClient, ledgerName, address, asset, color string, expected *big.Int) { + t.Helper() acct, err := actions.GetAccount(ctx, client, ledgerName, address) require.NoError(t, err, "failed to get account %s", address) - vol, ok := acct.Volumes[asset] - require.True(t, ok, "account %s has no volumes for asset %s", address, asset) + vol := acct.FindVolume(asset, color) + require.NotNil(t, vol, "account %s has no volumes for asset %s color %q", address, asset, color) - balance, ok := new(big.Int).SetString(vol.Balance, 10) - require.True(t, ok, "invalid balance %q for account %s asset %s", vol.Balance, address, asset) + balance, ok := new(big.Int).SetString(vol.GetBalance(), 10) + require.True(t, ok, "invalid balance %q for account %s asset %s color %q", + vol.GetBalance(), address, asset, color) require.Equal(t, 0, expected.Cmp(balance), - "account %s asset %s: expected balance %s, got %s", address, asset, expected.String(), balance.String()) + "account %s asset %s color %q: expected balance %s, got %s", + address, asset, color, expected.String(), balance.String()) } -// CheckNoNegativeBalances verifies no account has a negative balance, -// except for explicitly listed exceptions (e.g., @world, overdraft accounts). +// CheckNoNegativeBalances verifies no (account, asset, color) bucket has a +// negative balance, except for explicitly listed exceptions (e.g., @world, +// overdraft accounts). func CheckNoNegativeBalances(t *testing.T, ctx context.Context, client servicepb.BucketServiceClient, ledgerName string, exceptions []string) { t.Helper() @@ -284,14 +303,17 @@ func CheckNoNegativeBalances(t *testing.T, ctx context.Context, client servicepb require.NoError(t, err, "failed to list accounts for negative balance check") for _, acct := range accounts { - if exceptionSet[acct.Address] { + if exceptionSet[acct.GetAddress()] { continue } - for asset, vol := range acct.Volumes { - balance, ok := new(big.Int).SetString(vol.Balance, 10) - require.True(t, ok, "invalid balance %q for account %s asset %s", vol.Balance, acct.Address, asset) + for _, entry := range acct.GetVolumes() { + vol := entry.GetVolumes() + balance, ok := new(big.Int).SetString(vol.GetBalance(), 10) + require.True(t, ok, "invalid balance %q for account %s asset %s color %q", + vol.GetBalance(), acct.GetAddress(), entry.GetAsset(), entry.GetColor()) require.True(t, balance.Sign() >= 0, - "negative balance on account %s asset %s: %s", acct.Address, asset, balance.String()) + "negative balance on account %s asset %s color %q: %s", + acct.GetAddress(), entry.GetAsset(), entry.GetColor(), balance.String()) } } }