Skip to content

Phase 2: fee gate gRPC proxy #369

Description

@sadiq1971

Part of the Fee Module epic (#366). Depends on #368 (the engine). Design: docs/fee-module-design.md §6.

Front the same pkg/cantonsdk/fee engine with a deployable gRPC service (pkg/feegate) that speaks the Canton Ledger API. Write submissions get a CC fee leg injected; every other call is a transparent passthrough. Any service, in any language, points its endpoint at the proxy and authenticates with the same JWT it would use against Canton. This is a front-end over the engine — not a rewrite.

Why a proxy in addition to the library

Phase 1 — library Phase 2 — proxy
Integration import a Go package change one endpoint
Languages Go only any language
"No bypass" enforcement discipline (use wrapped client) network isolation (node reachable only via proxy)
API coverage calls the middleware makes all Ledger API calls, incl. future ones
Best when Go-only, minimize moving parts polyglot services, or fee guaranteed org-wide

Design

The proxy registers the Ledger API gRPC services and, for each, either injects a fee leg (writes) or forwards verbatim (everything else). It holds an upstream connection to the real Canton node and reuses the same fee.Gate built in Phase 1.

flowchart TB
  IN["Incoming Ledger API call"] --> Q{"write submission?"}
  Q -->|yes| INJ["decode -> Gate.Process -> forward augmented tx"]
  Q -->|"no — reads, streams, party mgmt, ..."| PASS["transparent passthrough"]
Loading

Write interception — CommandService

// pkg/feegate/command_service.go
type commandProxy struct {
    lapiv2.UnimplementedCommandServiceServer
    upstream lapiv2.CommandServiceClient
    gate     fee.Gate
}

func (p *commandProxy) SubmitAndWaitForTransaction(
    ctx context.Context, req *lapiv2.SubmitAndWaitForTransactionRequest,
) (*lapiv2.SubmitAndWaitForTransactionResponse, error) {
    payer := actAs(req.Commands)                        // acting party
    processed, err := p.gate.Process(ctx, payer, req.Commands.Commands)
    if err != nil {
        return nil, status.Errorf(codes.FailedPrecondition, "fee gate: %v", err)
    }
    req.Commands.Commands = processed.Commands          // augmented with CC fee leg
    return p.upstream.SubmitAndWaitForTransaction(forwardCtx(ctx), req)
}

The interactive prepare path (InteractiveSubmissionService.PrepareSubmission) is intercepted the same way, so the fee leg is inside the hash the client signs.

Passthrough — everything else

StateService, UpdateService, PartyManagementService, reads, streams, and any future service are registered as thin forwarders. A generic streaming forwarder covers server-streaming calls (e.g. GetUpdates, GetActiveContracts):

func forwardServerStream[Req any, Resp any](
    ctx context.Context, req *Req,
    open func(context.Context, *Req, ...grpc.CallOption) (grpc.ServerStreamingClient[Resp], error),
    send func(*Resp) error,
) error {
    up, err := open(forwardCtx(ctx), req)
    if err != nil { return err }
    for {
        msg, err := up.Recv()
        if errors.Is(err, io.EOF) { return nil }
        if err != nil { return err }
        if err := send(msg); err != nil { return err }
    }
}

Auth — JWT passthrough

The caller sends the same bearer JWT it would send to Canton; the proxy copies the authorization metadata onto the upstream context (forwardCtx) and does not mint its own token. The operator's fee-leg settlement relies on the pre-provisioned TransferPreapproval, not on the caller's identity.

func forwardCtx(ctx context.Context) context.Context {
    md, _ := metadata.FromIncomingContext(ctx)
    return metadata.NewOutgoingContext(ctx, md.Copy())
}

Wiring — cmd/feegate

gate := fee.NewGate(scan.NewOracle(cfg.ScanURL), fee.NewSizer(), cfg.FeeParty, cfg.PreapprovalCID, calcCfg, cfg.Mode)
up   := ledger.New(cfg.Upstream) // connection to the real Canton node

s := grpc.NewServer()
lapiv2.RegisterCommandServiceServer(s, &commandProxy{upstream: up.Command(), gate: gate})
lapiv2.RegisterInteractiveSubmissionServiceServer(s, &interactiveProxy{upstream: up.Interactive(), gate: gate})
lapiv2.RegisterStateServiceServer(s,  &statePassthrough{upstream: up.State()})
lapiv2.RegisterUpdateServiceServer(s, &updatePassthrough{upstream: up.Update()})
// ...party mgmt, etc. — passthrough

Deployment

Run feegate as its own service; restrict the Canton node's Ledger API so it's reachable only via the proxy (network isolation). Services swap their ledger endpoint to the proxy — no other change. Same fee.mode staging (Observe → Quote-only → Collect) as Phase 1.

Acceptance criteria

  • pkg/feegate + cmd/feegate gRPC service reusing the Phase 1 fee.Gate unchanged (no engine changes required — if the engine needs edits, that's a Phase 1 gap).
  • CommandService and InteractiveSubmissionService write paths inject the CC fee leg; fee leg is inside the signed hash on the interactive path.
  • StateService, UpdateService, party mgmt, and streaming calls pass through transparently (byte-for-byte responses; streams relayed).
  • JWT metadata forwarded upstream unchanged; proxy mints no token of its own.
  • fee.mode staging works identically to Phase 1.
  • Documented deployment with the node reachable only via the proxy (no-bypass network isolation).
  • Integration test: a Go client pointed at the proxy completes a transfer with the fee leg present, and a read/stream call returns identical results to hitting the node directly.

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions