Skip to content

Phase 1: in-process fee engine + gated ledger #368

Description

@sadiq1971

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

  • 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: FeatureAdded to issues and PRs to identify that the change is a new feature.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions