ChainSafe / ChainSafe/canton-middleware
Phase 1: in-process fee engine + gated ledger
- 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