ChainSafe / ChainSafe/canton-middleware

Phase 2: fee gate gRPC proxy

Open
#369 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 **#368** (the engine). Design: [`docs/fee-module-design.md`](../blob/main/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.

```mermaid
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"]
```

### Write interception — `CommandService`

```go
// 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`):

```go
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.

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

### Wiring — `cmd/feegate`

```go
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.

Contributor guide

No contributing guide indexed for this repository

Research direction

Read docs/fee-module-design.md §6 and inspect the Phase 1 pkg/cantonsdk/fee engine before starting. Then map the required services under pkg/feegate and cmd/feegate, beginning with CommandService and InteractiveSubmissionService, followed by passthrough and forwarding behavior. Done means the acceptance criteria pass, including JWT forwarding, staging modes, deployment documentation, and the proxy integration test.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
api, backend
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.