Skip to content

Add canton-daml settlement backend (additive port)#5

Draft
anvztor wants to merge 12 commits into
GOATNetwork:mainfrom
anvztor:canton/initial-port
Draft

Add canton-daml settlement backend (additive port)#5
anvztor wants to merge 12 commits into
GOATNetwork:mainfrom
anvztor:canton/initial-port

Conversation

@anvztor

@anvztor anvztor commented May 21, 2026

Copy link
Copy Markdown

PR description — canton/initial-port → main

Branch: anvztor/x402:canton/initial-portGOATNetwork/x402:main

Summary

Adds a complete canton-daml settlement backend to the x402 reference
implementation: Daml templates, a self-hosted facilitator (Go), a demo
merchant (Go), a CLI client (Go), and a browser SPA — all reachable via
a single docker compose up -d.

The contribution is 100 % additive. Every upstream file
(goatx402-sdk-server-go, goatx402-sdk, goatx402-sdk-server-ts,
goatx402-demo, goatx402-contract, API.md, DEVELOPER_FAST.md,
ONBOARDING.md, README.md) is unchanged.

What's new

goatx402-canton/         Daml templates + Canton bootstrap config + daml-sdk Dockerfile
goatx402-receipt/        Canonical receipt schema + offline verifier (Go module)
goatx402-facilitator/    HTTP server: x402 routes + Canton gRPC client (Go module)
goatx402-merchant/       Demo paywall: 402 issuer + offline receipt verifier (Go module)
goatx402-canton-cli/     Reference CLI client (Go module)
goatx402-canton-demo/    Vite/React SPA demo client
scripts/                 canton-up/down/smoke + init-custodial-keys
docs/canton/             this directory
docs/canton-receipt.schema.json   JSON Schema for CantonReceipt
docker-compose.yml       one-shot bring-up of the whole stack
.dockerignore            build-context optimisation
.github/workflows/canton.yml      3-job CI (baseline / canton-modules / canton-stack)
LICENSE-canton-port      Apache-2.0 scope-limited to net-new files
Makefile                 top-level orchestration (canton-up/down/smoke/e2e)
go.work                  workspace listing all 4 canton modules + upstream SDK

Upstream EVM stack files: not touched.

Design decisions (recorded in docs/canton/port-plan.html §1)

  1. Module prefix: github.com/goatnetwork/<pkgname> to match upstream's
    existing github.com/goatnetwork/goatx402-sdk-server shape (no /x402/
    path segment).
  2. Receipt module: kept standalone (goatx402-receipt/), not folded
    into facilitator. Reason: facilitator's internal/api/orders.go:446
    currently has canonical helpers that pkg/receipt doesn't export;
    folding would carry that duplication forward.
  3. Branch hosting: personal fork; can migrate to a GOATNetwork-org fork
    on request.
  4. LICENSE: Apache-2.0, scoped to net-new files only via
    LICENSE-canton-port. Does not relicense any inherited upstream file.
    A separate governance issue is being opened proposing a repo-level
    LICENSE for maintainers to decide.
  5. SDK extension dropped: upstream goatx402-sdk-server-go is a
    client SDK to GoatX402 Core (EVM). Its request/response bodies don't
    match the canton facilitator's (canton uses {merchant, payer, amount, currency, trustedIssuer, …}, Core uses {dapp_order_id, chain_id, token_symbol, …}), so just swapping AuthScheme wouldn't enable
    canton routing. A Canton-aware SDK client is out of scope for this
    port — happy to follow up if desired.

Testing

Quick start

docker compose up -d            # canton-localnet + daml-bootstrap + facilitator + merchant + canton-demo
curl http://localhost:8080/healthz                    # facilitator
curl -i http://localhost:7070/resource | head -5      # merchant returns 402
open http://localhost:4173                            # SPA

E2E

docker compose --profile e2e run --rm e2e-cli \
  --merchant-url   http://merchant:7070 \
  --facilitator    http://facilitator:8080 \
  --resource       /resource \
  --payer-token    "$(jq -r '.Alice' state/payer-tokens.json)" \
  --source-holding "$(jq -r '.contract_id' state/source-holding.json)"

Acceptance gates (G1-G4 from port-plan.html)

Gate Stage Status
G1 — facilitator builds + unit tests pass under new module paths Stage 2
G2 — e2e-smoke green under perf gate Stage 5 pending validator final review
G3 — branch CI green for 3 consecutive pushes Stage 7 pending workflow first run
G4 — internal team agrees branch is in good shape post-G3 pending

What's intentionally not in this PR

  • Spec/API.md additions registering the canton-daml scheme — propose
    as a follow-up doc PR once this lands.
  • TS SDK canton support (goatx402-sdk, goatx402-sdk-server-ts) — same
    pattern as the Go SDK refactor (also out of scope here).
  • Solidity callback contract changes — canton flow does not use the
    EVM callback contract.
  • Replacing GoatX402 Core — explicit non-goal. The canton facilitator is
    a sibling, not a replacement.

Things to look at first

If you only have 10 minutes, read in this order:

  1. docs/canton/port-plan.html §1-§4 — what's added and why.
  2. docs/canton/x402-canton-mapping.md — how the x402 envelope maps to
    Canton primitives.
  3. goatx402-canton/daml/Payment.daml (74 lines) — the entire Daml model.
  4. goatx402-facilitator/internal/api/router.go (~50 lines) — the HTTP surface.
  5. docker-compose.yml — how the pieces fit at runtime.

Open questions

  1. Module-path prefix preference: we chose github.com/goatnetwork/<pkg>
    to match goatx402-sdk-server-go's existing shape. Comfortable, or
    prefer github.com/goatnetwork/x402/<pkg>?
  2. Should this live in-tree (as siblings of goatx402-sdk-server-go) or
    in a separate GOATNetwork/x402-canton repo with cross-repo CI?
  3. Are you open to a follow-up that adds a canton-daml registry entry
    to API.md's scheme list?
  4. License preference for the repo root (currently absent)?

Companion change in GOATNetwork/giftcard

A parallel branch feat/canton-payment adds CantonX402SDK in
gift-api/internal/x402_canton/ so giftcard can use the canton facilitator
as an alternative settlement backend. That branch is local-only — the
giftcard repo has forks disabled, so it cannot be pushed via the standard
GitHub fork-PR flow. Available on request as a patch series or via direct
push by a maintainer.


Generated from docs/canton/port-plan.html. Internal review by Claude × Codex
cross-review (3 rounds; Round 3 verdict: ship with minor clarifications,
all applied). See docs/canton/port-plan.html footer for review trail.

anvztor added 12 commits May 21, 2026 22:16
Net-new files only; upstream files untouched.

- LICENSE-canton-port: Apache-2.0 scoped to net-new files (governance
  issue to follow for repo-level licensing); explicitly does not relicense
  inherited upstream files.
- Makefile: top-level orchestration with canton-up/down/smoke/e2e targets
  (stubs reference scripts/ to be filled in subsequent stages).
- go.work: lists goatx402-sdk-server-go (existing) only; canton modules
  added by Stage 2/3.
- .github/workflows/canton.yml: Stage 0 stub that sanity-checks the
  baseline upstream module continues to build.
- scripts/.gitkeep: placeholder for canton bootstrap scripts.
- docs/canton/: port-plan v3 + preflight-notes captured before Stage 1.

Stage 0 acceptance: go build ./goatx402-sdk-server-go/... and go vet exit 0
on a fresh clone with go.work added.

Refs port-plan.html §6 Stage 0.
Net-new files only. Brings up a fresh canton container alongside any
existing dev canton on the host by namespacing the ports to 5031/5032/5038/5039
(legacy 5011 range remains free for other dev work).

Added:
  goatx402-canton/daml/{daml.yaml,Payment.daml,Scripts/Topup.daml}
  goatx402-canton/{bootstrap.canton,simple-topology.conf}
  scripts/{canton-up,canton-down,canton-smoke,init-custodial-keys}.sh
  scripts/init-custodial-keys.bats

Changes vs canton-payment source:
  - canton-up.sh: self-contained docker-only mode (drops the SDK fallback;
    docker-compose unification in Task #9 standardises on this); image pinned
    by digest (sha256:98068c06…); ports default to the 5030 range.
  - bootstrap.canton: marker string updated to "=== goatx402 canton localnet
    ready ===" (canton-up.sh greps for this).
  - canton-smoke.sh: DAML_DIR rewritten to goatx402-canton/daml; CANTON_PORT
    default 5031.

Stage 1 acceptance:
  $ bash scripts/canton-up.sh   →  container up, marker present, exit 0
  $ docker ps --filter name=canton-localnet-goatx402 → Up

Stage 1 known gap: canton-smoke.sh still requires the `daml` CLI on host.
Task #9 (docker-compose unification) will wrap this in
digitalasset/daml-sdk:2.10.0 so no host install is needed.

Refs port-plan.html §6 Stage 1.
Pure additive port. Upstream module untouched.

Added (net-new top-level modules):
  goatx402-receipt/   (was pkg/receipt/, kept standalone per §1 decision)
  goatx402-facilitator/ (was facilitator/)
  docs/canton-receipt.schema.json  (referenced by receipt_test.go via
                                   walk-up filepath lookup)

Module-rename inventory applied (§5):
  github.com/goat-network/goat-canton-payment/pkg/receipt  →
    github.com/goatnetwork/goatx402-receipt
  github.com/goat-network/goat-canton-payment/facilitator  →
    github.com/goatnetwork/goatx402-facilitator
  Replace directive updated in facilitator/go.mod.
  Both modules: go 1.25.0.
  .proto files: go_package option rewritten (for future protoc regen).
  .pb.go files: re-copied unmodified from source (sed-rewriting them
                broke the embedded length-prefixed descriptor blobs,
                causing protobuf init panic; do NOT sed .pb.go).

go.work: extended to include both new modules.

Stage 2 acceptance:
  $ grep -r 'goat-canton-payment' . --exclude-dir=archive --exclude-dir=.git
      (zero hits outside docs/archive/)
  $ go vet ./goatx402-receipt/... ./goatx402-facilitator/... ./goatx402-sdk-server-go/...
      (exit 0)
  $ go test -short ./goatx402-receipt/... ./goatx402-facilitator/...
      (all packages OK, including the proto init and schema validation paths)

.gitignore: added state/, logs/, .daml/, go.sum.local for the upcoming
e2e + Daml-build flows.

Refs port-plan.html §6 Stage 2 + §5 module-rename inventory.
Net-new modules:
  goatx402-merchant/      (was merchant/, demo paywall + offline receipt verifier)
  goatx402-canton-cli/    (was client-cli/, x402-canton CLI client)

Module-rename inventory applied:
  github.com/goat-network/goat-canton-payment/merchant      → github.com/goatnetwork/goatx402-merchant
  github.com/goat-network/goat-canton-payment/client-cli    → github.com/goatnetwork/goatx402-canton-cli
  Replace directives updated to point at ../goatx402-receipt.

go.work: extended to 4 canton modules + upstream goatx402-sdk-server-go.

Stage 3 acceptance:
  $ go build  ./goatx402-merchant/... ./goatx402-canton-cli/...   (exit 0)
  $ go vet    ./goatx402-merchant/... ./goatx402-canton-cli/...   (exit 0)
  $ go test   ./goatx402-merchant/... ./goatx402-canton-cli/...   (all pass)

Refs port-plan.html §6 Stage 3 + §5 module-rename inventory.
Net-new: goatx402-canton-demo/ — Vite + React/TS SPA demo client for
the canton-daml scheme. Renamed package.json from
@goat-canton-payment/client-web to goatx402-canton-demo. No source-code
imports referenced the parent repo path; src/ moved verbatim.

Build artifacts excluded (node_modules/, dist/); regenerated via
pnpm install && pnpm run build at deployment time.

Stage 4 acceptance deferred to Stage 5 e2e (Playwright cross-sdk-parity).

Refs port-plan.html §6 Stage 4.
Net-new orchestration so the whole canton x402 stack starts with a
single `docker compose up -d` from a clean checkout. Replaces the
host-side daml-SDK + multi-script bring-up of canton-payment with a
fully container-driven workflow.

Services (top-level docker-compose.yml):
  canton-localnet   long-running canton participant + domain (image
                    digest-pinned per docs/canton/preflight-notes.md)
  daml-bootstrap    one-shot: build DAR, upload, allocate parties,
                    topup Alice with 100 USD-canton; writes
                    state/source-holding.json; built from
                    digitalasset/daml-sdk:2.10.0
  facilitator       Go service (multi-stage build → distroless/static),
                    :8080, talks to canton-localnet over the compose
                    network
  merchant          Go service, :7070, verifies receipts offline,
                    points at facilitator: via service name
  canton-demo       Vite SPA built with pnpm, served via nginx, :4173
  e2e-cli           opt-in (profile=e2e); runs the x402-canton CLI to
                    exercise the full flow inside the compose network

Dockerfiles:
  goatx402-facilitator/Dockerfile
  goatx402-merchant/Dockerfile
  goatx402-canton-cli/Dockerfile          (also used by e2e-cli service)
  goatx402-canton-demo/Dockerfile         (multi-stage: node→nginx)
  goatx402-canton/Dockerfile.bootstrap    (multi-stage: daml-sdk + tools)

Bootstrap script:
  goatx402-canton/canton-bootstrap.sh — runs inside daml-bootstrap container;
  idempotent DAR upload + party alloc + topup; writes
  state/source-holding.json for facilitator + merchant to consume.

Ports remain namespaced to 5031/5032/5038/5039 (canton) + 8080 (facilitator)
+ 7070 (merchant) + 4173 (demo) so this stack coexists with any other
canton dev environment on the host.

.dockerignore: speeds up build context transfers (drops .git, docs,
node_modules, dist, bin, .daml, state, logs).

Stage 5 + Task #9 acceptance is the next step — `docker compose up -d`
end-to-end smoke against the new compose stack.

Refs port-plan.html §6 Stage 5 + Task #9.
Net-new docs (branch-relative; no upstream paths touched):

  docs/canton/README.md
    Top-level orientation: quick-start (docker compose up -d),
    verification checks, module map, scheme rationale.

  docs/canton/operator-handbook.md
    Production hardening: real OIDC, HSM-backed signing keys,
    persistent postgres, monitoring, backup. Ported from canton-payment
    with paths rewritten for branch layout.

  docs/canton/x402-canton-mapping.md
    How the x402 envelope, scheme, accepts[], and proof relate to
    Canton primitives (Holding, PaymentRequest, Pay choice, receipt).
    Ported with branch-relative paths.

Refs port-plan.html §6 Stage 8.
Replaces the Stage 0 stub. Three jobs:

  baseline       — go build + go vet on goatx402-sdk-server-go (proves
                   upstream module still works under the new branch).
  canton-modules — go build + go vet + go test -short on all 4 canton
                   modules; plus a stale-import grep gate (no stray
                   "goat-canton-payment" Go imports outside .pb.go and
                   the documented CanaryMessage string).
  canton-stack   — docker compose up the full stack against canton
                   localnet; verifies facilitator /healthz and that
                   merchant returns 402 without X-PAYMENT. Slow but
                   the only job that proves the docker compose path
                   actually works end-to-end.

Caches Go's build cache + module cache (~/.cache/go-build, ~/go/pkg/mod).
Uploads docker compose logs as workflow artifact on failure for triage.

Refs port-plan.html §6 Stage 7.
The daml-sdk:2.10.0 image (multi-GB) was hanging on a registry retry
loop, blocking the docker-compose up -d path. Replaced with a
host-build-once / commit-once strategy:

  - goatx402-canton/dist/payment-0.0.1.dar (333 KB, committed)
    Pre-built with daml SDK 2.10.0 on the host. Re-build with
    `cd goatx402-canton/daml && daml build` when Payment.daml changes.

  - bootstrap.canton (extended): now uploads the DAR + allocates
    every party the stack needs (Issuer, Alice, facilitator, merchant)
    in a single canton-console transaction at canton-localnet start.

  - docker-compose.yml: removed the daml-bootstrap service entirely.
    facilitator now depends_on canton-localnet directly.

  - scripts/canton-init.sh + Makefile target `make canton-init`:
    the one remaining one-shot step (mint initial Holding for Alice
    via Daml Script Topup) runs on the host. Requires `daml` on PATH
    or at ~/.daml/bin/daml. Idempotent; safe to re-run.

  - .gitignore: explicit exception for goatx402-canton/dist/payment-*.dar
    so the DAR ships with the repo even though other dist/ dirs stay
    gitignored.

  - Removed: goatx402-canton/Dockerfile.bootstrap +
    goatx402-canton/canton-bootstrap.sh — superseded.

  - Service Dockerfiles: facilitator / merchant / canton-cli now
    generate a slim go.work inside the builder (only the modules they
    actually use), avoiding "module listed in go.work file" errors
    when sibling modules aren't COPY'd in.

Workflow becomes:
  $ docker compose up -d   # canton + facilitator + merchant + canton-demo
  $ make canton-init       # one-time data seed for Alice
  $ # ready for e2e

Refs port-plan.html §6 Stage 5 + Task #9.
… need

End-to-end smoke now passes. After:

  $ docker compose up -d canton-localnet
  $ # (wait for "goatx402 canton localnet ready" marker)
  $ make canton-init
  $ docker compose up -d facilitator merchant canton-demo

All four containers come up healthy:
  - canton-localnet: gRPC LedgerAPI :5031 (DAR uploaded, parties allocated)
  - facilitator:     HTTP :8080  → /healthz {status: ok}, /readyz {status: ready}
  - merchant:        HTTP :7070  → /resource returns HTTP 402 + canton-daml envelope
  - canton-demo:     HTTP :4173  → SPA serves 200

Changes (single fix-up commit so the bring-up sequence is correct):

  scripts/gen-signing-key.go  (new)
    Tiny Go helper that emits base64(raw 64-byte ed25519 private key) and
    base64(raw 32-byte ed25519 public key) — the formats LoadParticipantSigningKey
    and loadPubKey expect. openssl PEM/PKCS#8 doesn't match either.

  scripts/canton-init.sh
    - Filter parties by party-id prefix instead of display_name (canton
      parties.enable() doesn't set display_name).
    - Fix Daml-Script field name: TopupArgs has `payer`, not `owner`.
    - Use jq --arg (string) for contract_id, not --argjson (was failing
      on raw hex without quotes).
    - Generate participant signing key via gen-signing-key.go (correct format).
    - Write state/facilitator.env + state/merchant.env so docker-compose
      can `env_file:` load TRUSTED_ISSUER_MAP, CURRENCY_ALLOW_LIST,
      MERCHANT_PARTY_ID, MERCHANT_TRUSTED_ISSUER, MERCHANT_RESOURCE,
      MERCHANT_AMOUNT, MERCHANT_CURRENCY.

  docker-compose.yml
    - facilitator: env_file: ./state/facilitator.env + new env var
      DEV_SOURCE_HOLDING_FIXTURE_PATH=/state/source-holding-map.json so
      the dev source-holding endpoint can resolve Alice's mint.
    - merchant: env_file: ./state/merchant.env (replaces the file-path
      hacks like TRUSTED_ISSUER_FILE / MERCHANT_FILE which the merchant
      config doesn't actually support).

Stage 5 + Task #9 acceptance gates: green.

Refs port-plan.html §6 Stage 5.
- facilitator: distroless/static → distroless/base (CGO sqlite needs glibc)
- facilitator: explicit GOOS=linux on build
- merchant   : CGO_ENABLED=0 + GOOS=linux (static binary; works on distroless/static)
- canton-cli : CGO_ENABLED=0 + GOOS=linux (same)
- canton-demo: node:20 → node:22; pin pnpm 9.15.0 (pnpm 11 hit
              ERR_UNKNOWN_BUILTIN_MODULE on node:20); drop --frozen-lockfile
              (the package name was renamed from @goat-canton-payment/client-web)

Without these fixes the containers built but exited with
"exec /usr/local/bin/X: no such file or directory" (distroless/static
missing glibc loader for CGO binary) or never built at all (node/pnpm
incompat).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant