Part of the Fee Module epic (#366). Depends on #367 (CC balance visibility). Design: 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.
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.
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.
// 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):
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:
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
Part of the Fee Module epic (#366). Depends on #367 (CC balance visibility). Design:
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/feeTransport-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.Internals (all behind
Gate):fee = trafficCC + amuletFeeOnFee + buffer, always rounding the buffer up.TransferPreapprovalso it settles with the user's single signature.fee_coverage_ratio.2. In-process integration — wrap the ledger client
The middleware submits via the
ledger.Ledgerinterface (Command(),Interactive(), …). Introduce aGatedLedgerthat implements the same interface, delegates everything, and injects the fee leg on write paths. Services keep their existing logic; only wiring changes.Wiring (in
pkg/app/api/server.go, the one place the ledger client is built):3. Surface the quote in the prepare response
Add an optional
Quotetotoken.PreparedTransfer(pkg/cantonsdk/token/types.go) and populate it fromGate.Quote/Processso the client sees the fee before signing:Expose it in the HTTP prepare response DTO. This is the only user-facing change.
4. Rollout modes
Mode is a single config value (
fee.mode); no code changes to move between stages.Prerequisites
TransferPreapproval(single-signature receipt).Acceptance criteria
pkg/cantonsdk/feeengine withGate(Process/Quote/Estimate),Oracle,Sizer,Calculator, fee-leg builder, reconciler — no HTTP/DB/config imports (enforced by review + a package-boundary test).fee = trafficCC + amuletFeeOnFee + buffer, buffer rounded up; unit tests prove operator-net ≥ trafficCC across a price/size matrix.GatedLedgerwrapsledger.Ledger; reads/streams/party-mgmt pass through; fee leg appended within the same signed transaction on the interactive prepare path.TransferPreapprovaland settles with the user's single signature (verified on ledger).FeeQuotesurfaced in the prepare HTTP response.fee.modeconfig drives Observe/Quote-only/Collect with no code change; default Observe.fee_coverage_ratiometric.