ChainSafe / ChainSafe/canton-middleware

Phase 1: in-process fee engine + gated ledger

Open
#368 0 comments 0 reactions 0 assignees View on GitHub
Type: Feature
Dominant language
Go
Stars
1
Forks
1
Avg merge
40m
Merged PRs (30d)
1

Description

Part of the Fee Module epic (#366). Depends on **#367** (CC balance visibility). Design: [`docs/fee-module-design.md`](../blob/main/docs/fee-module-design.md) §5.

Build the reusable, transport-free fee **engine** (`pkg/cantonsdk/fee`) and integrate it in-process by **wrapping the ledger client**, so the middleware's token/bridge/relayer code is untouched. Rollout is staged: **Observe → Quote-only → Collect**.

## 1. The engine — `pkg/cantonsdk/fee`

Transport-free: depends only on Canton API types (`lapiv2`, `interactivev2`) + stdlib. **No HTTP, DB, config files, or server runtime** — those live in the caller. The engine never learns how forwarding happens; that's what lets Phase 2 reuse it unchanged.

```go
package fee

// Gate is the engine entrypoint. Process takes the user's commands and returns
// them augmented with a CC fee leg plus the quote describing the charge.
type Gate interface {
// Process appends a CC fee leg (payer -> operator fee party) to cmds and
// returns the augmented command set with the quote. In Observe/Quote-only
// modes it returns cmds unchanged and a quote with Collected=false.
Process(ctx context.Context, payer string, cmds []*lapiv2.Command) (Processed, error)
// Quote sizes the fee for a would-be submission without mutating commands.
Quote(ctx context.Context, cmds []*lapiv2.Command) (Quote, error)
// Estimate sizes the fee from a byte count only (for pre-build UIs).
Estimate(ctx context.Context, bytes int) (Quote, error)
}

type Processed struct {
Commands []*lapiv2.Command
Quote Quote
}

type Quote struct {
Bytes int // sized tx bytes (Layer A basis)
TrafficCC decimal.Decimal // node burn: bytes * trafficPrice / ccPrice
AmuletFeeOnFee decimal.Decimal // Layer-B fee incurred by the CC leg itself
Buffer decimal.Decimal // rounded-up over-collect
TotalCC decimal.Decimal // TrafficCC + AmuletFeeOnFee + Buffer
CCPriceUSD decimal.Decimal // oracle price used
Mode Mode // Observe | QuoteOnly | Collect
Collected bool // true only in Collect mode
}
```

Internals (all behind `Gate`):

- **Sizer** — estimates sequenced bytes for a command set (Layer A basis).
- **Oracle** — live CC/USD price. Interface only; the Scan HTTP client is injected by the service (boundary rule).
- **Calculator** — the no-loss math: `fee = trafficCC + amuletFeeOnFee + buffer`, always rounding the buffer up.
- **Fee-Leg Builder** — builds the CC transfer command (payer → operator fee party) using the operator's live `TransferPreapproval` so it settles with the user's single signature.
- **Reconciler** — compares real burn (from node metrics) vs. collected CC; exposes `fee_coverage_ratio`.

```go
type Oracle interface { CCPriceUSD(ctx context.Context) (decimal.Decimal, error) }
type Sizer interface { SizeBytes(cmds []*lapiv2.Command) (int, error) }

type Mode int
const ( Observe Mode = iota; QuoteOnly; Collect )

func NewGate(o Oracle, s Sizer, feeParty string, preapprovalCID string, cfg CalcConfig, mode Mode) Gate
```

## 2. In-process integration — wrap the ledger client

The middleware submits via the `ledger.Ledger` interface (`Command()`, `Interactive()`, …). Introduce a `GatedLedger` that implements the same interface, delegates everything, and injects the fee leg on write paths. Services keep their existing logic; only wiring changes.

```go
// pkg/cantonsdk/fee/gatedledger.go
type GatedLedger struct {
ledger.Ledger // embed: reads, streams, party mgmt pass through untouched
gate Gate
}

func NewGatedLedger(inner ledger.Ledger, gate Gate) *GatedLedger {
return &GatedLedger{Ledger: inner, gate: gate}
}

// Only the submit surface is overridden. For the interactive prepare/execute
// flow the fee leg must be appended BEFORE the prepared-transaction hash is
// produced, so it is covered by the same single signature.
func (g *GatedLedger) Command() lapiv2.CommandServiceClient {
return &gatedCommandService{inner: g.Ledger.Command(), gate: g.gate}
}
```

Wiring (in `pkg/app/api/server.go`, the one place the ledger client is built):

```go
oracle := scan.NewOracle(cfg.Fee.ScanURL) // service-owned I/O
gate := fee.NewGate(oracle, fee.NewSizer(), cfg.Fee.Party, cfg.Fee.PreapprovalCID, calcCfg, cfg.Fee.Mode)
ledgerClient = fee.NewGatedLedger(ledgerClient, gate) // everything downstream unchanged
```

## 3. Surface the quote in the prepare response

Add an optional `Quote` to `token.PreparedTransfer` (`pkg/cantonsdk/token/types.go`) and populate it from `Gate.Quote`/`Process` so the client sees the fee before signing:

```go
type PreparedTransfer struct {
// ...existing fields...
FeeQuote *fee.Quote // nil when fees are disabled or in Observe mode
}
```

Expose it in the HTTP prepare response DTO. This is the only user-facing change.

## 4. Rollout modes

- **Observe** — build the quote, log real cost vs. would-be charge, collect nothing. Calibrate the no-loss math against real traffic.
- **Quote-only** — return the quote to clients, still collect nothing.
- **Collect** — append the CC fee leg; no-loss enforced.

Mode is a single config value (`fee.mode`); no code changes to move between stages.

## Prerequisites

- Operator fee party holding CC with a live `TransferPreapproval` (single-signature receipt).
- Scan API URL for live prices.
- Validator auto-topup enabled.

## Acceptance criteria

- [ ] `pkg/cantonsdk/fee` engine with `Gate` (`Process`/`Quote`/`Estimate`), `Oracle`, `Sizer`, `Calculator`, fee-leg builder, reconciler — no HTTP/DB/config imports (enforced by review + a package-boundary test).
- [ ] No-loss calculator: `fee = trafficCC + amuletFeeOnFee + buffer`, buffer rounded up; unit tests prove operator-net ≥ trafficCC across a price/size matrix.
- [ ] `GatedLedger` wraps `ledger.Ledger`; reads/streams/party-mgmt pass through; fee leg appended within the same signed transaction on the interactive prepare path.
- [ ] Fee leg uses the operator's `TransferPreapproval` and settles with the user's single signature (verified on ledger).
- [ ] `FeeQuote` surfaced in the prepare HTTP response.
- [ ] `fee.mode` config drives Observe/Quote-only/Collect with no code change; default Observe.
- [ ] Fails fast (clear error, no subsidy) when the payer holds insufficient CC.
- [ ] Reconciler exposes `fee_coverage_ratio` metric.
- [ ] Token/bridge/relayer business logic unchanged (diff limited to engine + wiring + one DTO field).

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with docs/fee-module-design.md §5, then inspect pkg/cantonsdk/fee, the ledger.Ledger submit and interactive paths, pkg/app/api/server.go, and pkg/cantonsdk/token/types.go. The work is complete when the gated engine, integration, quote response, rollout modes, no-loss tests, package-boundary test, and coverage reconciliation meet the listed acceptance criteria without changing token, bridge, or relayer logic.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
api, backend, backend-api-design, payments
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.