diff --git a/.gitignore b/.gitignore index acba749..5a0c720 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,46 @@ release/ .hnld/ +.hnld-*/ +.hnld.bak-*/ +# Node homes from before the cbdcd -> hnld rename; still on disk locally. +.cbdcd/ +.cbdcd-*/ +.cbdcd.bak-*/ *.out *.html -bin/ \ No newline at end of file +bin/ +# The same binaries when a build runs without -o bin/ and drops them in the repo +# root instead -- `go build ./...` at the root does this for every main package. +# Anchored so only the build output is caught, not the cmd/ package directories +# that share these names. Listed as they occur rather than pre-emptively. +/corridord +/qbftattestor +/qbftproofapi +.claude/ + +# Per-run corridor state: attestor logs, seen-set, relay height cursors. Written +# under $PWD by scripts/corridor/{up-corridor,autorelay}.sh (STATE_ROOT), so it +# lands in the repo root whenever the corridor is driven from here. +.corridor/ + +# relayer signing keys — never commit +# Filled-in relayer signing keys, one file per corridor leg. Glob rather than a +# single name: a second leg means a second keys file, and a private key that is +# only ignored if someone remembers to extend this list is not ignored. +scripts/corridor/relayer-keys*.json* +!scripts/corridor/relayer-keys.example.json + +# attestor signing keys — never commit, and never lose either. The attestor set is +# fixed in AttestationLightClient's constructor with no setter, so a key that only +# ever existed in a shell scrollback costs a light-client redeploy to replace. +scripts/corridor/attestor-key*.json* +# The same key in the Web3 keystore form cosmos/ibc-attestor reads, plus the +# password that opens it. Both are as sensitive as the hex above: together they +# ARE the hex, and separately neither is useful. +scripts/corridor/attestor-keystore* + + +# Working docs — kept locally, deliberately untracked +docs/*.md diff --git a/.golangci.yml b/.golangci.yml index 8e63ebc..0788332 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,6 +41,14 @@ linters-settings: suggest-new: true misspell: locale: US + ignore-words: + # Not prose: `Misbehaviour` is a protobuf-generated type in + # qbftclient.pb.go, registered in x/qbftclient/codec.go, and it spells the + # ibc-go interface methods this module must implement -- + # CheckForMisbehaviour and UpdateStateOnMisbehaviour. Upstream uses the + # British form, so it is an external contract, not a choice. Prose keeps + # the US locale; only the identifier is exempt. + - Misbehaviour nolintlint: allow-unused: false allow-leading-space: true diff --git a/Dockerfile b/Dockerfile index 0daab6a..83ab592 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,17 @@ RUN make build FROM base AS integration +RUN make lint +# Unit tests -- test-unit, not test-poa: the latter runs ./x/poa/... only, which +# left x/qbftclient, x/cbdc and app/ibc/corridorpause unenforced by CI. +RUN make test-unit +# Integration tests +RUN make test-integration +# Simulation tests +# TODO: Restore simulation tests if possible +# RUN make test-sim-benchmark-simulation +# RUN make test-sim-full-app-fast + RUN touch /test.lock FROM golang:1.23.8 AS release diff --git a/Makefile b/Makefile index 9e86fd0..9769775 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,19 @@ install: go.sum build: go build $(BUILD_FLAGS) -o ./bin/hnld ./cmd/hnld +# The operator binaries a corridor leg needs, on top of the chain daemon. A +# separate target rather than more lines in `build`: that target means "the chain +# daemon" to scripts/ibcv2-devnet/up.sh and to the runbook's T3.2, and +# build-rocksdb recurses into it — a corridor tool has no business being rebuilt +# by a RocksDB chain build. +# +# qbftattestor is deliberately absent: the attestor runs from a published image +# (DEC-32 adopted cosmos/ibc-attestor), and cmd/qbftattestor is the standby path. +build-corridor: build + go build $(BUILD_FLAGS) -o ./bin/qbftinit ./cmd/qbftinit + go build $(BUILD_FLAGS) -o ./bin/qbftproofapi ./cmd/qbftproofapi + go build $(BUILD_FLAGS) -o ./bin/attestcheck ./cmd/attestcheck + build-rocksdb: # Make sure to run this command with root permission CGO_ENABLED=1 CGO_CFLAGS="-I/usr/include" \ @@ -132,7 +145,11 @@ lint-fix: ### Testing ### ############################################################################### EXCLUDED_POA_PACKAGES=$(shell go list ./x/poa/... | grep -v /x/poa/testutil | grep -v /x/poa/client | grep -v /x/poa/simulation | grep -v /x/poa/types) -EXCLUDED_UNIT_PACKAGES=$(shell go list ./... | grep -v tests | grep -v testutil | grep -v tools | grep -v app | grep -v docs | grep -v cmd | grep -v /x/poa/testutil | grep -v /x/poa/client | grep -v /x/poa/simulation | grep -v /x/poa/types) +# Anchored on /app so only the app package itself is dropped -- its sole test is +# TestFullAppSimulation, which needs the -Enabled/-NumBlocks/-Params flags the +# test-sim-* targets pass and panics without them. The unanchored filter this +# replaces also dropped app/ibc/corridorpause, i.e. the corridor pause middleware. +EXCLUDED_UNIT_PACKAGES=$(shell go list ./... | grep -v tests | grep -v testutil | grep -v tools | grep -v '/app$$' | grep -v docs | grep -v cmd | grep -v /x/poa/testutil | grep -v /x/poa/client | grep -v /x/poa/simulation | grep -v /x/poa/types) mocks: @echo "--> Installing mockgen" @@ -140,12 +157,16 @@ mocks: @echo "--> Generating mocks" @./scripts/mockgen.sh -test: test-poa test-integration test-sim-benchmark-simulation test-sim-full-app-fast +test: test-unit test-integration test-sim-benchmark-simulation test-sim-full-app-fast test-integration: @echo "--> Running integration testsuite" @go test -mod=readonly -tags=test -v ./tests/integration +test-unit: + @echo "--> Running unit tests" + @go test $(EXCLUDED_UNIT_PACKAGES) + test-poa: @echo "--> Running POA tests" @go test $(EXCLUDED_POA_PACKAGES) diff --git a/app/app.go b/app/app.go index 022824c..f62f39c 100644 --- a/app/app.go +++ b/app/app.go @@ -13,6 +13,8 @@ import ( "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/x/auth/posthandler" "github.com/peersyst/cbdc-node/app/ante" + "github.com/peersyst/cbdc-node/app/ibc/corridorpause" + "github.com/peersyst/cbdc-node/app/ibc/ratelimitv2guard" "github.com/ethereum/go-ethereum/common" @@ -38,6 +40,7 @@ import ( "github.com/cosmos/gogoproto/proto" ratelimit "github.com/cosmos/ibc-apps/modules/rate-limiting/v10" ratelimittypes "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/types" + ratelimitv2 "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/v2" ibcclienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" ibcconnectiontypes "github.com/cosmos/ibc-go/v10/modules/core/03-connection/types" ibctesting "github.com/cosmos/ibc-go/v10/testing" @@ -123,6 +126,7 @@ import ( ibc "github.com/cosmos/ibc-go/v10/modules/core" ibcporttypes "github.com/cosmos/ibc-go/v10/modules/core/05-port/types" + ibcapi "github.com/cosmos/ibc-go/v10/modules/core/api" ibcexported "github.com/cosmos/ibc-go/v10/modules/core/exported" ibckeeper "github.com/cosmos/ibc-go/v10/modules/core/keeper" @@ -132,12 +136,15 @@ import ( cbdctypes "github.com/peersyst/cbdc-node/x/cbdc/types" poakeeper "github.com/peersyst/cbdc-node/x/poa/keeper" poatypes "github.com/peersyst/cbdc-node/x/poa/types" + "github.com/peersyst/cbdc-node/x/qbftclient" + qbfttypes "github.com/peersyst/cbdc-node/x/qbftclient/types" srvflags "github.com/cosmos/evm/server/flags" "github.com/cosmos/evm/x/erc20" erc20keeper "github.com/cosmos/evm/x/erc20/keeper" erc20types "github.com/cosmos/evm/x/erc20/types" + erc20v2 "github.com/cosmos/evm/x/erc20/v2" "github.com/cosmos/evm/x/feemarket" feemarketkeeper "github.com/cosmos/evm/x/feemarket/keeper" feemarkettypes "github.com/cosmos/evm/x/feemarket/types" @@ -151,6 +158,7 @@ import ( transfer "github.com/cosmos/ibc-go/v10/modules/apps/transfer" transferkeeper "github.com/cosmos/ibc-go/v10/modules/apps/transfer/keeper" ibctransfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" + transferv2 "github.com/cosmos/ibc-go/v10/modules/apps/transfer/v2" // Force-load the tracer engines to trigger registration due to Go-Ethereum v1.10.15 changes _ "github.com/ethereum/go-ethereum/eth/tracers/js" @@ -556,6 +564,14 @@ func New( ) app.TransferKeeper.SetAddressCodec(evmaddress.NewEvmCodec(sdk.GetConfig().GetBech32AccountAddrPrefix())) + // Route v1 outbound packets through the rate limit keeper, which implements + // ICS4Wrapper. Constructed with the channel keeper above, the transfer keeper + // would send straight to core IBC and outbound quotas would never be applied + // (inbound is unaffected: it runs through the IBCModule stack). Only the v1 + // send path uses the ICS4Wrapper; v2 accounting happens in ratelimitv2's + // OnSendPacket, so this does not double count. + app.TransferKeeper.WithICS4Wrapper(app.RateLimitKeeper) + transferModule := transfer.NewAppModule(app.TransferKeeper) // Create the app.ICAHostKeeper app.ICAHostKeeper = icahostkeeper.NewKeeper( @@ -642,12 +658,38 @@ func New( AddRoute(ibctransfertypes.ModuleName, transferStack) app.IBCKeeper.SetRouter(ibcRouter) + /**** IBC V2 ****/ + + // create IBC v2 transfer stack from bottom to top of stack, mirroring v1 + var transferStackV2 ibcapi.IBCModule + transferStackV2 = transferv2.NewIBCModule(app.TransferKeeper) + transferStackV2 = ratelimitv2.NewIBCMiddleware(app.RateLimitKeeper, transferStackV2) + // ratelimitv2 passes v2 sends with no client-keyed quota silently; the guard + // emits an event and logs them, without blocking the packet. + transferStackV2 = ratelimitv2guard.NewIBCMiddleware(app.RateLimitKeeper, transferStackV2) + transferStackV2 = erc20v2.NewIBCMiddleware(transferStackV2, app.Erc20Keeper) + + // Gov emergency stop per corridor, outermost so a paused client is refused + // before anything escrows, mints or converts. x/cbdc's issuance_paused does not + // reach IBC, and would stop domestic transfers too. + transferStackV2 = corridorpause.NewIBCMiddleware(app.CbdcKeeper, transferStackV2) + + // Create static IBC v2 router, add transfer route, then set it (SetRouterV2 does not seal) + ibcRouterV2 := ibcapi.NewRouter() + ibcRouterV2.AddRoute(ibctransfertypes.PortID, transferStackV2) + app.IBCKeeper.SetRouterV2(ibcRouterV2) + clientKeeper := app.IBCKeeper.ClientKeeper storeProvider := app.IBCKeeper.ClientKeeper.GetStoreProvider() tmLightClientModule := ibctm.NewLightClientModule(appCodec, storeProvider) clientKeeper.AddRoute(ibctm.ModuleName, &tmLightClientModule) + // Besu/QBFT counterparty light client. Needs no ibc-go change (AddRoute takes + // any exported.LightClientModule) and no param update (AllowedClients wildcard). + qbftLightClientModule := qbftclient.NewLightClientModule(appCodec, storeProvider) + clientKeeper.AddRoute(qbfttypes.ClientType, &qbftLightClientModule) + /**** Module Hooks ****/ // register hooks after all modules have been initialized @@ -719,6 +761,11 @@ func New( app.BasicModuleManager.RegisterLegacyAminoCodec(cdc) app.BasicModuleManager.RegisterInterfaces(interfaceRegistry) + // The QBFT light client has no AppModule (no genesis, no state outside the + // client store), so its proto types are registered here; without this the codec + // cannot unmarshal them into ibc-go's interface types. + qbftclient.RegisterInterfaces(interfaceRegistry) + // NOTE: upgrade module is required to be prioritized app.mm.SetOrderPreBlockers( upgradetypes.ModuleName, diff --git a/app/ibc/corridorpause/middleware.go b/app/ibc/corridorpause/middleware.go new file mode 100644 index 0000000..64b17e3 --- /dev/null +++ b/app/ibc/corridorpause/middleware.go @@ -0,0 +1,143 @@ +// Package corridorpause gives governance an emergency stop for an IBC corridor. +// +// Why it exists: x/cbdc's issuance_paused switch is checked only in the module's +// own mint and burn path (keeper/mint.go, keeper/burn.go). It does not touch the +// transfer module, IBC, or the bank send path — so with issuance paused, the +// central bank cannot mint, while tokens continue to flow across every corridor. +// For an incident where the corridor itself is the problem, the switch everyone +// reaches for did nothing. +// +// The levers that existed before this one were all wrong in some way: emptying +// allowed_relayers works but is signed by the client creator rather than +// governance; rate limits are percentage quotas meant for shaping flow, and +// cannot exist before a denom has supply; freezing the client needs genuine +// misbehavior evidence; and disabling bank sends for the denom also stops every +// domestic transfer. +// +// This middleware is the missing lever: gov-controlled, per-corridor, and it +// stops both directions. +package corridorpause + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + "github.com/cosmos/ibc-go/v10/modules/core/api" +) + +// EventTypeRecvPaused is emitted when an inbound packet is rejected on a paused +// corridor. ibc-go runs OnRecvPacket in a cached context and, on failure, +// discards the cache but re-emits its events through ConvertToErrorEvents, +// which prefixes both the type and every attribute key. What lands on chain is +// therefore type "ibccallbackerror-ibc_corridor_recv_paused" with attributes +// "ibccallbackerror-client_id" and "ibccallbackerror-sequence" -- alarms must +// subscribe to the prefixed names; the middleware cannot avoid the prefix. +// +// The send side deliberately emits nothing: OnSendPacket refuses by returning +// an error, which fails the whole transaction, and baseapp keeps only ante +// events for a failed tx -- anything emitted by the message itself is dropped. +// x/cbdc emits cbdc_corridor_pause at the governance action instead, which is +// the reliable signal for both directions. +const ( + EventTypeRecvPaused = "ibc_corridor_recv_paused" + AttributeKeyClientID = "client_id" + AttributeKeySequence = "sequence" +) + +// ParamsGetter is the slice of the cbdc keeper this middleware needs. Keeping it +// to one method means the middleware can be tested without a keeper. +type ParamsGetter interface { + IsIBCClientPaused(ctx sdk.Context, clientID string) bool +} + +var _ api.IBCModule = (*IBCMiddleware)(nil) + +// IBCMiddleware refuses packets on paused corridors. +type IBCMiddleware struct { + app api.IBCModule + params ParamsGetter +} + +// NewIBCMiddleware wraps app so both packet directions honor the pause. +func NewIBCMiddleware(params ParamsGetter, app api.IBCModule) IBCMiddleware { + return IBCMiddleware{app: app, params: params} +} + +// OnSendPacket refuses to originate a transfer on a paused corridor. +// +// Returning an error here fails the sending transaction, so nothing is escrowed +// and the sender keeps their funds. +func (im IBCMiddleware) OnSendPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + payload channeltypesv2.Payload, + signer sdk.AccAddress, +) error { + if im.params.IsIBCClientPaused(ctx, sourceClient) { + return fmt.Errorf("ibc corridor %s is paused by governance", sourceClient) + } + return im.app.OnSendPacket(ctx, sourceClient, destinationClient, sequence, payload, signer) +} + +// OnRecvPacket rejects an inbound transfer on a paused corridor. +// +// It returns a *failed* recv result rather than an error, which is the important +// choice: a failure acknowledgement travels back to the counterparty and refunds +// its sender. Erroring instead would leave the packet stuck until it timed out, +// holding the sender's funds in escrow on the other chain for the duration. +// +// The destination client is the one checked here — that is this chain's name for +// the corridor, the same id governance pauses. +func (im IBCMiddleware) OnRecvPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + payload channeltypesv2.Payload, + relayer sdk.AccAddress, +) channeltypesv2.RecvPacketResult { + if im.params.IsIBCClientPaused(ctx, destinationClient) { + ctx.EventManager().EmitEvent(sdk.NewEvent( + EventTypeRecvPaused, + sdk.NewAttribute(AttributeKeyClientID, destinationClient), + sdk.NewAttribute(AttributeKeySequence, fmt.Sprint(sequence)), + )) + return channeltypesv2.RecvPacketResult{Status: channeltypesv2.PacketStatus_Failure} + } + return im.app.OnRecvPacket(ctx, sourceClient, destinationClient, sequence, payload, relayer) +} + +// OnTimeoutPacket is deliberately not gated. +// +// A timeout refunds a sender whose packet was never delivered. Blocking it while +// paused would strand exactly the funds the pause is meant to protect. +func (im IBCMiddleware) OnTimeoutPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + payload channeltypesv2.Payload, + relayer sdk.AccAddress, +) error { + return im.app.OnTimeoutPacket(ctx, sourceClient, destinationClient, sequence, payload, relayer) +} + +// OnAcknowledgementPacket is deliberately not gated, for the same reason as +// timeouts: an acknowledgement settles a transfer that already happened. Refusing +// it would leave in-flight packets unresolved on both sides, which is worse than +// the state the pause was called to stop. +func (im IBCMiddleware) OnAcknowledgementPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + acknowledgement []byte, + payload channeltypesv2.Payload, + relayer sdk.AccAddress, +) error { + return im.app.OnAcknowledgementPacket(ctx, sourceClient, destinationClient, sequence, acknowledgement, payload, relayer) +} diff --git a/app/ibc/corridorpause/middleware_test.go b/app/ibc/corridorpause/middleware_test.go new file mode 100644 index 0000000..b74a8fb --- /dev/null +++ b/app/ibc/corridorpause/middleware_test.go @@ -0,0 +1,172 @@ +package corridorpause_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + "github.com/peersyst/cbdc-node/app/ibc/corridorpause" +) + +// pausedSet answers the one question the middleware asks. +type pausedSet map[string]bool + +func (p pausedSet) IsIBCClientPaused(_ sdk.Context, clientID string) bool { return p[clientID] } + +// spyApp records which callbacks reached the wrapped application. +type spyApp struct { + sent, recvd, timedOut, acked bool +} + +func (s *spyApp) OnSendPacket(sdk.Context, string, string, uint64, channeltypesv2.Payload, sdk.AccAddress) error { + s.sent = true + return nil +} + +func (s *spyApp) OnRecvPacket(sdk.Context, string, string, uint64, channeltypesv2.Payload, sdk.AccAddress) channeltypesv2.RecvPacketResult { + s.recvd = true + return channeltypesv2.RecvPacketResult{Status: channeltypesv2.PacketStatus_Success} +} + +func (s *spyApp) OnTimeoutPacket(sdk.Context, string, string, uint64, channeltypesv2.Payload, sdk.AccAddress) error { + s.timedOut = true + return nil +} + +func (s *spyApp) OnAcknowledgementPacket(sdk.Context, string, string, uint64, []byte, channeltypesv2.Payload, sdk.AccAddress) error { + s.acked = true + return nil +} + +func newCtx() sdk.Context { + return sdk.Context{}.WithEventManager(sdk.NewEventManager()) +} + +const ( + paused = "qbft-0" + open = "qbft-1" +) + +func TestSendIsRefusedOnAPausedCorridor(t *testing.T) { + app := &spyApp{} + mw := corridorpause.NewIBCMiddleware(pausedSet{paused: true}, app) + ctx := newCtx() + + err := mw.OnSendPacket(ctx, paused, "07-tendermint-0", 1, channeltypesv2.Payload{}, nil) + if err == nil { + t.Fatal("a send on a paused corridor must fail") + } + if app.sent { + t.Error("the packet must not reach the transfer stack — nothing may be escrowed") + } + + // Deliberately no event: baseapp discards everything a failed message + // emitted (only ante events survive), so a send-side emission could never + // be observed on chain. x/cbdc's cbdc_corridor_pause is the real signal. + if got := ctx.EventManager().Events(); len(got) != 0 { + t.Errorf("a refused send must emit nothing -- %d event(s) would be silently dropped on chain", len(got)) + } +} + +// Pausing one corridor must not touch the others. At N countries a blanket pause +// is an outage rather than an incident response. +func TestOtherCorridorsAreUnaffected(t *testing.T) { + app := &spyApp{} + mw := corridorpause.NewIBCMiddleware(pausedSet{paused: true}, app) + + if err := mw.OnSendPacket(newCtx(), open, "07-tendermint-0", 1, channeltypesv2.Payload{}, nil); err != nil { + t.Fatalf("an unpaused corridor must still send: %v", err) + } + if !app.sent { + t.Error("the packet should have reached the transfer stack") + } +} + +// A blocked receive must produce a failure acknowledgement, not an error. The +// acknowledgement travels back and refunds the counterparty's sender; an error +// would leave the packet stuck until timeout with their funds escrowed. +func TestRecvIsRejectedWithAFailureAck(t *testing.T) { + app := &spyApp{} + mw := corridorpause.NewIBCMiddleware(pausedSet{paused: true}, app) + ctx := newCtx() + + res := mw.OnRecvPacket(ctx, "07-tendermint-0", paused, 1, channeltypesv2.Payload{}, nil) + if res.Status != channeltypesv2.PacketStatus_Failure { + t.Errorf("status = %v, want Failure", res.Status) + } + if app.recvd { + t.Error("the packet must not reach the transfer stack — no voucher may be minted") + } + // The event is the operator's per-packet pause signal. ibc-go re-emits it + // with every name prefixed "ibccallbackerror-" because the recv failed; + // what we assert here are the unprefixed originals. + events := ctx.EventManager().Events() + if len(events) != 1 { + t.Fatalf("expected exactly one event, got %d", len(events)) + } + ev := events[0] + if ev.Type != corridorpause.EventTypeRecvPaused { + t.Errorf("event type = %q, want %q", ev.Type, corridorpause.EventTypeRecvPaused) + } + attrs := map[string]string{} + for _, a := range ev.Attributes { + attrs[a.Key] = a.Value + } + if attrs[corridorpause.AttributeKeyClientID] != paused { + t.Errorf("client_id = %q, want %q", attrs[corridorpause.AttributeKeyClientID], paused) + } + if attrs[corridorpause.AttributeKeySequence] != "1" { + t.Errorf("sequence = %q, want \"1\"", attrs[corridorpause.AttributeKeySequence]) + } +} + +// The receive side is keyed on the destination client, which is this chain's name +// for the corridor and the id governance pauses. Checking the source id instead +// would silently fail to pause anything. +func TestRecvChecksTheDestinationClient(t *testing.T) { + app := &spyApp{} + mw := corridorpause.NewIBCMiddleware(pausedSet{paused: true}, app) + + // paused id appears as the SOURCE here; this chain's client is open. + res := mw.OnRecvPacket(newCtx(), paused, open, 1, channeltypesv2.Payload{}, nil) + if res.Status != channeltypesv2.PacketStatus_Success { + t.Error("a corridor this chain has not paused must still receive") + } + if !app.recvd { + t.Error("the packet should have reached the transfer stack") + } +} + +// Timeouts and acknowledgements settle transfers that already happened. Blocking +// them during a pause would strand exactly the funds the pause protects. +func TestTimeoutAndAckAreNeverBlocked(t *testing.T) { + app := &spyApp{} + mw := corridorpause.NewIBCMiddleware(pausedSet{paused: true}, app) + + if err := mw.OnTimeoutPacket(newCtx(), paused, paused, 1, channeltypesv2.Payload{}, nil); err != nil { + t.Errorf("a timeout must not be blocked by a pause: %v", err) + } + if !app.timedOut { + t.Error("the timeout must reach the transfer stack so the sender is refunded") + } + + if err := mw.OnAcknowledgementPacket(newCtx(), paused, paused, 1, nil, channeltypesv2.Payload{}, nil); err != nil { + t.Errorf("an acknowledgement must not be blocked by a pause: %v", err) + } + if !app.acked { + t.Error("the acknowledgement must reach the transfer stack so the transfer settles") + } +} + +func TestNothingIsPausedByDefault(t *testing.T) { + app := &spyApp{} + mw := corridorpause.NewIBCMiddleware(pausedSet{}, app) + + if err := mw.OnSendPacket(newCtx(), paused, open, 1, channeltypesv2.Payload{}, nil); err != nil { + t.Errorf("an empty pause list must pause nothing: %v", err) + } + if res := mw.OnRecvPacket(newCtx(), open, paused, 1, channeltypesv2.Payload{}, nil); res.Status != channeltypesv2.PacketStatus_Success { + t.Error("an empty pause list must not block receives") + } +} diff --git a/app/ibc/ratelimitv2guard/middleware.go b/app/ibc/ratelimitv2guard/middleware.go new file mode 100644 index 0000000..eba774e --- /dev/null +++ b/app/ibc/ratelimitv2guard/middleware.go @@ -0,0 +1,125 @@ +// Package ratelimitv2guard makes the IBC v2 rate-limit gap observable. +// +// The upstream ratelimitv2 middleware (cosmos/ibc-apps rate-limiting) is a no-op +// when no rate limit is configured for a packet's (denom, client) pair: it lets +// the packet through unmetered and says nothing (keeper/flow.go: "If there's no +// rate limit yet for this denom, no action is necessary"). Because v2 quotas are +// keyed by CLIENT id -- separate from v1's CHANNEL-keyed quotas -- a v2 upgrade +// that forgets to add them leaves every v2 outflow entirely unlimited, silently. +// +// This middleware wraps ratelimitv2 and emits an event plus an error log whenever +// a v2 SEND flows without a matching client-keyed quota, turning that silent gap +// into a loud, queryable signal. It never blocks the packet: enforcement stays +// with ratelimitv2; this only observes. +package ratelimitv2guard + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + ratelimitkeeper "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/keeper" + ratelimittypes "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/types" + transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + "github.com/cosmos/ibc-go/v10/modules/core/api" +) + +// Event emitted when a v2 send is not covered by any client-keyed rate limit. +const ( + EventTypeUnratelimitedSend = "ibc_v2_unratelimited_send" + AttributeKeyDenom = "denom" + AttributeKeyClientID = "client_id" + AttributeKeyAmount = "amount" +) + +// RateLimitChecker is the slice of the rate-limit keeper this guard needs. It is +// satisfied by ratelimitkeeper.Keeper, the same keeper ratelimitv2 enforces with. +type RateLimitChecker interface { + GetRateLimit(ctx sdk.Context, denom, channelOrClientID string) (ratelimittypes.RateLimit, bool) +} + +var _ api.IBCModule = (*IBCMiddleware)(nil) + +// IBCMiddleware wraps a v2 IBCModule and reports outbound transfers that no +// client-keyed quota covers. +type IBCMiddleware struct { + app api.IBCModule + keeper RateLimitChecker +} + +// NewIBCMiddleware wraps app so its send path is checked for rate-limit coverage. +func NewIBCMiddleware(k RateLimitChecker, app api.IBCModule) IBCMiddleware { + return IBCMiddleware{app: app, keeper: k} +} + +func (im IBCMiddleware) OnSendPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + payload channeltypesv2.Payload, + signer sdk.AccAddress, +) error { + im.warnIfUnratelimited(ctx, sourceClient, payload) + return im.app.OnSendPacket(ctx, sourceClient, destinationClient, sequence, payload, signer) +} + +// warnIfUnratelimited emits a loud, on-chain signal when an outbound v2 transfer +// has no client-keyed quota. It derives the denom exactly as ratelimitv2 does -- +// via the same exported ParseDenomFromSendPacket -- so the lookup here matches the +// key the enforcing middleware would use. It never alters the packet. +func (im IBCMiddleware) warnIfUnratelimited(ctx sdk.Context, sourceClient string, payload channeltypesv2.Payload) { + data, err := transfertypes.UnmarshalPacketData(payload.Value, payload.Version, payload.Encoding) + if err != nil { + // Not a transfer payload we can read; ratelimitv2 surfaces the conversion error. + return + } + denom := ratelimitkeeper.ParseDenomFromSendPacket(transfertypes.FungibleTokenPacketData{Denom: data.Token.Denom.Path()}) + if _, found := im.keeper.GetRateLimit(ctx, denom, sourceClient); found { + return // a client-keyed quota exists; ratelimitv2 meters it + } + + ctx.EventManager().EmitEvent(sdk.NewEvent( + EventTypeUnratelimitedSend, + sdk.NewAttribute(AttributeKeyDenom, denom), + sdk.NewAttribute(AttributeKeyClientID, sourceClient), + sdk.NewAttribute(AttributeKeyAmount, data.Token.Amount), + )) + ctx.Logger().Error( + "IBC v2 outbound transfer is not rate limited: no client-keyed quota configured for this denom/client", + "denom", denom, "client_id", sourceClient, "amount", data.Token.Amount, + ) +} + +func (im IBCMiddleware) OnRecvPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + payload channeltypesv2.Payload, + relayer sdk.AccAddress, +) channeltypesv2.RecvPacketResult { + return im.app.OnRecvPacket(ctx, sourceClient, destinationClient, sequence, payload, relayer) +} + +func (im IBCMiddleware) OnTimeoutPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + payload channeltypesv2.Payload, + relayer sdk.AccAddress, +) error { + return im.app.OnTimeoutPacket(ctx, sourceClient, destinationClient, sequence, payload, relayer) +} + +func (im IBCMiddleware) OnAcknowledgementPacket( + ctx sdk.Context, + sourceClient string, + destinationClient string, + sequence uint64, + acknowledgement []byte, + payload channeltypesv2.Payload, + relayer sdk.AccAddress, +) error { + return im.app.OnAcknowledgementPacket(ctx, sourceClient, destinationClient, sequence, acknowledgement, payload, relayer) +} diff --git a/app/upgrades.go b/app/upgrades.go index 3dd2dbf..11e4ba5 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -1,13 +1,23 @@ package app import ( + "context" "errors" upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" appupgrades "github.com/peersyst/cbdc-node/app/upgrades" ) +// UpgradeNameIBCV2 is the on-chain upgrade name that activates IBC v2 (Eureka) +// support. The binary already wires the v2 transfer stack and router in New(); +// v2 state lives in the core IBC store and needs no new store key or migration, +// so this handler only runs module migrations (a no-op given no module +// consensus versions changed). It exists so the live POA chain can switch to +// the v2-capable binary through the coordinated validator-vote upgrade flow. +const UpgradeNameIBCV2 = "ibc-v2" + var rollingUpgradeHandlers = []struct { name string handler upgradetypes.UpgradeHandler @@ -23,6 +33,12 @@ var rollingUpgradeHandlers = []struct { } func (app *App) setupUpgradeHandlers() { + app.UpgradeKeeper.SetUpgradeHandler( + UpgradeNameIBCV2, + func(ctx context.Context, _ upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { + return app.mm.RunMigrations(ctx, app.configurator, fromVM) + }, + ) } func (app *App) setupPreBlockUpgradeHandlers(ctx sdk.Context) error { diff --git a/cmd/attestcheck/main.go b/cmd/attestcheck/main.go new file mode 100644 index 0000000..8300fac --- /dev/null +++ b/cmd/attestcheck/main.go @@ -0,0 +1,134 @@ +// Command attestcheck reports which key an attestor is actually signing with, +// by asking it for a real attestation and recovering the signer from the +// signature. +// +// # WHY NOT JUST ASK IT +// +// cmd/qbftattestor served GET /address and up-corridor.sh compared that against +// the configured key. cosmos/ibc-attestor serves no such endpoint -- and the +// endpoint was always the weaker check anyway, because it reports what a +// process BELIEVES rather than what it can prove. Recovering the address from a +// signature over a payload we independently reconstruct proves possession. +// +// The check it enables is not academic. The attestor set is fixed in +// AttestationLightClient's constructor with no setter, so an attestor signing +// with the wrong key produces proofs the client rejects as an unknown signer, +// and the only repair is redeploying the client and migrating the id behind it. +// The failure also looks like a dozen unrelated things at 3am. Catching it at +// bring-up costs one RPC. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" + apb "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" +) + +func main() { + var ( + grpcAddr = flag.String("grpc", "127.0.0.1:8091", "attestor AttestationService address") + cbdcRPC = flag.String("cbdc-rpc", "http://127.0.0.1:26657", "cbdc-node CometBFT RPC") + expect = flag.String("expect", "", "if set, exit non-zero unless the recovered address matches") + ) + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + conn, err := grpc.NewClient(*grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + //nolint:gocritic // exiting main; the OS reclaims what the defer would have released + log.Fatalf("dial %s: %v", *grpcAddr, err) + } + defer conn.Close() + cli := apb.NewAttestationServiceClient(conn) + + // A height a few blocks back: the tip may not be readable everywhere yet, + // and attesting an unreadable height is a different failure than a wrong key. + tip, err := latestHeight(ctx, *cbdcRPC) + if err != nil { + log.Fatalf("cbdc rpc: %v", err) + } + // Guard before subtracting: tip is unsigned, so tip-5 on a chain shorter + // than 5 blocks wraps to ~1.8e19 and the height check below never fires. + if tip < 7 { + log.Fatalf("chain is only %d blocks tall; nothing safe to attest yet", tip) + } + height := tip - 5 + + resp, err := cli.StateAttestation(ctx, &apb.StateAttestationRequest{Height: height}) + if err != nil { + log.Fatalf("StateAttestation(%d): %v", height, err) + } + att := resp.GetAttestation() + if att == nil || len(att.GetSignature()) != 65 { + log.Fatalf("attestor returned no usable signature for height %d", height) + } + + // Recover over the digest the CONTRACT checks, rebuilt here from the + // attested data. Recovering over anything else would prove nothing about + // what the light client will accept. + digest := attestor.Digest(att.GetAttestedData(), attestor.TagState) + sig := append([]byte(nil), att.GetSignature()...) + // go-ethereum wants v in {0,1}; the contract's ECDSA.recover wants 27/28, + // and that is how it comes off the wire. + if sig[64] >= 27 { + sig[64] -= 27 + } + pub, err := crypto.SigToPub(digest[:], sig) + if err != nil { + log.Fatalf("cannot recover signer: %v", err) + } + addr := crypto.PubkeyToAddress(*pub) + + fmt.Printf("%s\n", addr.Hex()) + if *expect != "" && !strings.EqualFold(*expect, addr.Hex()) { + fmt.Fprintf(os.Stderr, + "MISMATCH: attestor at %s signs as %s, expected %s.\n"+ + " The expected address is the one baked into AttestationLightClient's\n"+ + " constructor. Signing with any other key produces proofs the client\n"+ + " rejects as an unknown signer, and the set has no setter.\n", + *grpcAddr, addr.Hex(), *expect) + os.Exit(1) + } +} + +func latestHeight(ctx context.Context, rpc string) (uint64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rpc+"/status", nil) + if err != nil { + return 0, err + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + var out struct { + Result struct { + SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + } `json:"sync_info"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return 0, err + } + var h uint64 + if _, err := fmt.Sscanf(out.Result.SyncInfo.LatestBlockHeight, "%d", &h); err != nil { + return 0, fmt.Errorf("unparseable height %q", out.Result.SyncInfo.LatestBlockHeight) + } + return h, nil +} diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go new file mode 100644 index 0000000..bbf5b98 --- /dev/null +++ b/cmd/corridord/chains.go @@ -0,0 +1,644 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/crypto" +) + +// Both clients shell out to cast/hnld for anything that signs or proves. +// That is deliberate: this daemon owns orchestration, not custody, and the +// proving tools are the ones already exercised against the live rig. + +type besuClient struct { + rpc string + + // lastScanned is the last block the SendPacket scan covered. Subsequent + // eth_getLogs queries start here instead of 0, so the scan window stops + // growing with chain length. + lastScanned uint64 + // ackScanned is the same cursor for WriteAcknowledgement logs. Separate + // from lastScanned because the two scans advance at different rates, and + // sharing one cursor would let whichever runs first skip the other's logs. + ackScanned uint64 +} + +type sendPacket struct { + sequence uint64 + txHash string +} + +// besuLogsMaxRange caps each eth_getLogs window. Besu enforces an RPC block +// range limit (5000 by default) and rejects the WHOLE query above it, so a +// cursor still at 0 -- every fresh start on a chain older than the limit -- +// would error on every tick forever: no inbound packets, no outbound acks, +// escrows stranded. Chunking keeps each query legal no matter how far behind +// the cursor is. +const besuLogsMaxRange = 4000 + +type rawLog struct { + Topics []string `json:"topics"` + Data string `json:"data"` + TxHash string `json:"transactionHash"` + BlockNumber string `json:"blockNumber"` +} + +// logsSince pages eth_getLogs for topic0 on address from block `from` to the +// current head in besuLogsMaxRange windows. It returns the logs plus the last +// block the scan covered: every window names an explicit numeric toBlock the +// node answered for, so unlike "latest" the caller may safely resume from +// there. On ANY error nothing is returned and the cursor value passed in is +// handed back -- advancing past a window whose logs the caller never +// processed would drop those packets permanently. +func (b *besuClient) logsSince(ctx context.Context, address, topic0 string, from uint64) ([]rawLog, uint64, error) { + headRaw, err := b.call(ctx, "eth_blockNumber", []any{}) + if err != nil { + return nil, from, err + } + var headHex string + if err := json.Unmarshal(headRaw, &headHex); err != nil { + return nil, from, err + } + head, err := strconv.ParseUint(strings.TrimPrefix(headHex, "0x"), 16, 64) + if err != nil { + return nil, from, fmt.Errorf("eth_blockNumber %q: %w", headHex, err) + } + var out []rawLog + for start := from; start <= head; start += besuLogsMaxRange { + end := start + besuLogsMaxRange - 1 + if end > head { + end = head + } + res, err := b.call(ctx, "eth_getLogs", []any{map[string]any{ + "address": address, "fromBlock": fmt.Sprintf("0x%x", start), "toBlock": fmt.Sprintf("0x%x", end), "topics": []any{topic0}, + }}) + if err != nil { + return nil, from, err + } + var logs []rawLog + if err := json.Unmarshal(res, &logs); err != nil { + return nil, from, err + } + out = append(out, logs...) + } + if head > from { + from = head + } + return out, from, nil +} + +type writeAck struct { + sequence uint64 + ack []byte // the RAW app acknowledgement, exactly as ackPacket wants it +} + +func (b *besuClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) { + body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.rpc, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out struct { + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if out.Error != nil { + return nil, fmt.Errorf("%s: %s", method, out.Error.Message) + } + return out.Result, nil +} + +// sendPackets lists SendPacket events emitted by the router for clientID. +func (b *besuClient) sendPackets(ctx context.Context, router, clientID string) ([]sendPacket, error) { + // keccak256("SendPacket(string,uint256,(uint64,string,string,uint64,(string,string,string,string,bytes)[]))") + const topic = "0xab3a4458a269be61dfa43faa33aa7b1f5d570716f83ad078bc2ba5dab039abae" + // A string indexed parameter is stored as the KECCAK256 of the string, so + // Topics[1] must be matched against the hash of our client id -- without + // this, a second client on the same router would have its sequences + // relayed as ours. EqualFold because eth_getLogs returns lowercase hex. + clientTopic := crypto.Keccak256Hash([]byte(clientID)).Hex() + // The cursor advances to the last block logsSince covered; block `covered` + // itself is re-scanned next tick (fromBlock is inclusive), which is + // harmless -- the caller's receipt check drops duplicates. + logs, covered, err := b.logsSince(ctx, router, topic, b.lastScanned) + if err != nil { + return nil, err + } + b.lastScanned = covered + out := make([]sendPacket, 0, len(logs)) + for _, l := range logs { + if len(l.Topics) < 3 || !strings.EqualFold(l.Topics[1], clientTopic) { + continue + } + seq, err := strconv.ParseUint(strings.TrimPrefix(l.Topics[2], "0x"), 16, 64) + if err != nil { + continue + } + out = append(out, sendPacket{sequence: seq, txHash: l.TxHash}) + } + return out, nil +} + +// writeAckDataArgs decodes the non-indexed payload of WriteAcknowledgement: +// (Packet packet, bytes[] acknowledgements). The packet tuple is decoded only +// because abi offsets require it; what the corridor needs is the RAW app ack, +// which is what ackPacket wants back -- NOT the protobuf wrapper cbdc-node +// events carry. +var ( + besuPacketTupleTy, _ = abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "sequence", Type: "uint64"}, + {Name: "sourceClient", Type: "string"}, + {Name: "destClient", Type: "string"}, + {Name: "timeoutTimestamp", Type: "uint64"}, + {Name: "payloads", Type: "tuple[]", Components: []abi.ArgumentMarshaling{ + {Name: "sourcePort", Type: "string"}, + {Name: "destPort", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "encoding", Type: "string"}, + {Name: "value", Type: "bytes"}, + }}, + }) + bytesArrTy, _ = abi.NewType("bytes[]", "", nil) + writeAckDataArgs = abi.Arguments{{Type: besuPacketTupleTy}, {Type: bytesArrTy}} +) + +// writeAcks lists WriteAcknowledgement events for packets this chain received +// on clientID -- each one is an ack cbdc-node is still waiting for. +func (b *besuClient) writeAcks(ctx context.Context, router, clientID string) ([]writeAck, error) { + // Derived at runtime rather than hardcoded like the SendPacket topic: a + // silently wrong hash here would just mean "no acks, ever", which looks + // exactly like a quiet corridor. + topic := crypto.Keccak256Hash([]byte( + "WriteAcknowledgement(string,uint256,(uint64,string,string,uint64,(string,string,string,string,bytes)[]),bytes[])", + )).Hex() + clientTopic := crypto.Keccak256Hash([]byte(clientID)).Hex() + // Cursor discipline mirrors sendPackets: advance to the last block the + // chunked scan covered, re-scanning that block next tick. + logs, covered, err := b.logsSince(ctx, router, topic, b.ackScanned) + if err != nil { + return nil, err + } + b.ackScanned = covered + out := make([]writeAck, 0, len(logs)) + for _, l := range logs { + if len(l.Topics) < 3 || !strings.EqualFold(l.Topics[1], clientTopic) { + continue + } + seq, err := strconv.ParseUint(strings.TrimPrefix(l.Topics[2], "0x"), 16, 64) + if err != nil { + continue + } + data, err := hex.DecodeString(strings.TrimPrefix(l.Data, "0x")) + if err != nil { + return nil, fmt.Errorf("ack event for seq %d: bad data hex: %w", seq, err) + } + // A decode failure is an error, not a skip: skipping would silently + // strand this packet's commitment on cbdc-node forever. + vals, err := writeAckDataArgs.Unpack(data) + if err != nil { + return nil, fmt.Errorf("ack event for seq %d: decode: %w", seq, err) + } + acks, ok := vals[1].([][]byte) + if !ok || len(acks) != 1 { + return nil, fmt.Errorf("ack event for seq %d: expected exactly 1 ack (single-payload rig), got %d", seq, len(acks)) + } + out = append(out, writeAck{sequence: seq, ack: acks[0]}) + } + return out, nil +} + +// packetReceived reports whether a receipt already exists on Besu, so a restart +// does not redeliver. +func (b *besuClient) packetReceived(ctx context.Context, router, clientID string, seq uint64) (bool, error) { + return b.commitmentSet(ctx, router, receiptCommitmentKey(clientID, seq)) +} + +// commitmentSet reports whether the router holds ANY value under a commitment +// key (receipt, send commitment -- the store is shared). +func (b *besuClient) commitmentSet(ctx context.Context, router, key string) (bool, error) { + out, err := run(ctx, "cast", "call", "-r", b.rpc, router, + "getCommitment(bytes32)(bytes32)", key) + if err != nil { + // Fail CLOSED: an unreachable node must not be read as "not set", which + // for a receipt would redeliver every tick and for a send commitment + // would mark the ack relayed when it was not. + return false, fmt.Errorf("commitment check failed, refusing to assume: %w", err) + } + return !strings.Contains(out, "0x0000000000000000000000000000000000000000000000000000000000000000"), nil +} + +func (b *besuClient) send(ctx context.Context, pk, to, sig string, arg []byte) error { + _, err := run(ctx, "cast", "send", "-r", b.rpc, "--private-key", pk, to, sig, "0x"+hex.EncodeToString(arg)) + return err +} + +func (b *besuClient) recvPacket(ctx context.Context, pk, router, tuple string, proof []byte, height uint64) error { + arg := fmt.Sprintf("(%s,0x%s,(0,%d))", tuple, hex.EncodeToString(proof), height) + _, err := run(ctx, "cast", "send", "-r", b.rpc, "--private-key", pk, router, + "recvPacket(((uint64,string,string,uint64,(string,string,string,string,bytes)[]),bytes,(uint64,uint64)))", arg) + return err +} + +// ackPacket delivers a cbdc-node acknowledgement to the router, which clears +// the send commitment -- the step whose absence left escrow commitments set +// for packets cbdc-node had long since received and acknowledged. +func (b *besuClient) ackPacket(ctx context.Context, pk, router, tuple string, ack, proof []byte, height uint64) error { + arg := fmt.Sprintf("(%s,0x%s,0x%s,(0,%d))", tuple, hex.EncodeToString(ack), hex.EncodeToString(proof), height) + _, err := run(ctx, "cast", "send", "-r", b.rpc, "--private-key", pk, router, + "ackPacket(((uint64,string,string,uint64,(string,string,string,string,bytes)[]),bytes,bytes,(uint64,uint64)))", arg) + return err +} + +type cbdcRPC struct{ rpc string } + +func (c *cbdcRPC) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.rpc+path, nil) + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // CometBFT serves JSON-RPC errors as non-200 with a JSON body whose + // fields decode into `out` as zero values -- exactly the shape a + // legitimate empty result has. Refuse here so no caller can read an + // error as an answer. Capped read, same as askAttestor. + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + return fmt.Errorf("GET %s: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(msg))) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *cbdcRPC) latestHeight(ctx context.Context) (uint64, error) { + var out struct { + Result struct { + SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + } `json:"sync_info"` + } `json:"result"` + } + if err := c.get(ctx, "/status", &out); err != nil { + return 0, err + } + return strconv.ParseUint(out.Result.SyncInfo.LatestBlockHeight, 10, 64) +} + +type searchedTx struct { + Hash string `json:"hash"` + TxResult struct { + Events []struct { + Type string `json:"type"` + Attributes []struct { + Key string `json:"key"` + Value string `json:"value"` + } `json:"attributes"` + } `json:"events"` + } `json:"tx_result"` +} + +type txSearch struct { + Result struct { + Txs []searchedTx `json:"txs"` + // total_count is a STRING in CometBFT's JSON, not a number. + TotalCount string `json:"total_count"` + } `json:"result"` +} + +// searchTxs pages through /tx_search for query. tx_search caps per_page at 100 +// and serves page 1 by default, so a single query silently truncates once the +// chain has more than 100 matching txs: packets past the cap would never be +// relayed, funds staying escrowed with nothing logged. Page through until +// total_count txs are accounted for. +func (c *cbdcRPC) searchTxs(ctx context.Context, query string) ([]searchedTx, error) { + const perPage = 100 + var res []searchedTx + for page := 1; ; page++ { + q := url.Values{} + q.Set("query", query) + q.Set("per_page", strconv.Itoa(perPage)) + q.Set("page", strconv.Itoa(page)) + var out txSearch + if err := c.get(ctx, "/tx_search?"+q.Encode(), &out); err != nil { + return nil, err + } + res = append(res, out.Result.Txs...) + total, err := strconv.Atoi(out.Result.TotalCount) + if err != nil { + return nil, fmt.Errorf("tx_search total_count %q: %w", out.Result.TotalCount, err) + } + // A short or empty page also terminates: trusting total_count alone + // would spin forever against a server that misreports it high. + if len(res) >= total || len(out.Result.Txs) < perPage { + break + } + } + return res, nil +} + +func (c *cbdcRPC) sendEvents(ctx context.Context, clientID string) (map[uint64]string, error) { + txs, err := c.searchTxs(ctx, fmt.Sprintf("\"send_packet.packet_source_client='%s'\"", clientID)) + if err != nil { + return nil, err + } + res := map[uint64]string{} + for _, tx := range txs { + for _, ev := range tx.TxResult.Events { + if ev.Type != "send_packet" { + continue + } + var seq uint64 + var pkt string + for _, a := range ev.Attributes { + switch a.Key { + case "packet_sequence": + seq, _ = strconv.ParseUint(a.Value, 10, 64) + case "encoded_packet_hex": + pkt = a.Value + } + } + if seq != 0 && pkt != "" { + res[seq] = pkt + } + } + } + return res, nil +} + +type ackEvent struct { + packetHex string // protobuf packet, for the solidity tuple ackPacket needs + ackHex string // protobuf channeltypesv2.Acknowledgement (the WRAPPER, not the raw app ack) +} + +// ackEvents returns every acknowledgement this chain has written for packets +// received on clientID -- keyed by the packet's DESTINATION client, because +// that is the id the write_acknowledgement event (and the ack store key) +// carries for inbound packets. +func (c *cbdcRPC) ackEvents(ctx context.Context, clientID string) (map[uint64]ackEvent, error) { + txs, err := c.searchTxs(ctx, fmt.Sprintf("\"write_acknowledgement.packet_dest_client='%s'\"", clientID)) + if err != nil { + return nil, err + } + res := map[uint64]ackEvent{} + for _, tx := range txs { + for _, ev := range tx.TxResult.Events { + if ev.Type != "write_acknowledgement" { + continue + } + var seq uint64 + var e ackEvent + for _, a := range ev.Attributes { + switch a.Key { + case "packet_sequence": + seq, _ = strconv.ParseUint(a.Value, 10, 64) + case "encoded_packet_hex": + e.packetHex = a.Value + case "encoded_acknowledgement_hex": + e.ackHex = a.Value + } + } + if seq != 0 && e.packetHex != "" && e.ackHex != "" { + res[seq] = e + } + } + } + return res, nil +} + +// allSentSequences returns EVERY sequence this client has ever sent, not a +// "pending" set: nothing here filters against outstanding commitments, even +// though the ack leg now clears them. The caller's receipt check is what +// separates delivered from undelivered -- the honest name keeps the monotonic +// growth from reading like a filtering bug. +func (c *cbdcRPC) allSentSequences(ctx context.Context, clientID string) ([]uint64, error) { + evs, err := c.sendEvents(ctx, clientID) + if err != nil { + return nil, err + } + out := make([]uint64, 0, len(evs)) + for seq := range evs { + out = append(out, seq) + } + return out, nil +} + +func (c *cbdcRPC) packetHex(ctx context.Context, clientID string, seq uint64) (string, error) { + evs, err := c.sendEvents(ctx, clientID) + if err != nil { + return "", err + } + p, ok := evs[seq] + if !ok { + return "", fmt.Errorf("no send_packet event for sequence %d", seq) + } + return p, nil +} + +// packetReceived checks the receipt on cbdc-node so restarts do not redeliver. +func (c *cbdcRPC) packetReceived(ctx context.Context, clientID string, seq uint64) (bool, error) { + return c.storeValueSet(ctx, receiptPath(clientID, seq)) +} + +// commitmentSet checks whether cbdc-node still holds the send commitment for +// seq. MsgAcknowledgement (and MsgTimeout) delete it, so "still set" is what +// separates acks that still need relaying from ones already delivered. +func (c *cbdcRPC) commitmentSet(ctx context.Context, clientID string, seq uint64) (bool, error) { + return c.storeValueSet(ctx, commitmentPath(clientID, seq)) +} + +func (c *cbdcRPC) storeValueSet(ctx context.Context, path []byte) (bool, error) { + q := url.Values{} + q.Set("path", `"store/ibc/key"`) + q.Set("data", "0x"+hex.EncodeToString(path)) + var out struct { + Result struct { + Response struct { + Code uint32 `json:"code"` + Log string `json:"log"` + Value string `json:"value"` + } `json:"response"` + } `json:"result"` + } + if err := c.get(ctx, "/abci_query?"+q.Encode(), &out); err != nil { + return false, err + } + if out.Result.Response.Code != 0 { + // Fail CLOSED, mirroring besuClient.commitmentSet: a failed query must + // not be read as "not set", which for a receipt would redeliver every + // tick and for a send commitment would mark the packet refunded -- + // stranding its escrow silently -- when it was merely unqueried. + return false, fmt.Errorf("abci_query failed, refusing to assume: code=%d log=%q", + out.Result.Response.Code, out.Result.Response.Log) + } + return out.Result.Response.Value != "", nil +} + +// receiptPath is clientID || 0x02 || be64(seq) -- kind 2 is the receipt. +func receiptPath(clientID string, seq uint64) []byte { + return ics24Path(clientID, 0x02, seq) +} + +// commitmentPath is clientID || 0x01 || be64(seq) -- kind 1 is the send +// commitment, the entry that holds the escrow until an ack or timeout clears it. +func commitmentPath(clientID string, seq uint64) []byte { + return ics24Path(clientID, 0x01, seq) +} + +func ics24Path(clientID string, kind byte, seq uint64) []byte { + p := append([]byte(clientID), kind) + var be [8]byte + for i := 0; i < 8; i++ { + be[7-i] = byte(seq >> (8 * i)) + } + return append(p, be[:]...) +} + +// receiptCommitmentKey is what ICS26Router.getCommitment expects: the KECCAK256 +// of the receipt path, not the path itself. Passing the raw path returns zero +// for every packet, which silently turns the "already delivered?" check into +// "always redeliver" -- the defect this replaces. Redelivery was caught only by +// IBC's own replay protection, which is not a design. +func receiptCommitmentKey(clientID string, seq uint64) string { + return crypto.Keccak256Hash(receiptPath(clientID, seq)).Hex() +} + +// sendCommitmentKey is the router storage key for an outstanding send +// commitment on Besu: keccak256 of the kind 1 path. Non-zero means the escrow +// behind that packet is still held; ackPacket clearing it is the point of the +// ack leg. +func sendCommitmentKey(clientID string, seq uint64) string { + return crypto.Keccak256Hash(commitmentPath(clientID, seq)).Hex() +} + +// solidityTuple converts a protobuf packet into the tuple cast needs, using the +// same packetconv the manual runs used. +func solidityTuple(packetHex string) (string, error) { + out, err := run(context.Background(), "go", "run", "./cmd/packetconv", "-to-solidity", packetHex) + if err != nil { + return "", err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + return strings.TrimSpace(lines[len(lines)-1]), nil +} + +// relayInbound proves a Besu packet and delivers it on cbdc-node. qbftrelay +// emits an UNSIGNED tx; hnld signs it. This daemon never holds the key. +func (d *driver) relayInbound(ctx context.Context, p sendPacket) error { + conv, err := run(ctx, "go", "run", "./cmd/packetconv", "-besu-rpc", d.cfg.besuRPC, "-tx", p.txHash) + if err != nil { + return fmt.Errorf("packetconv: %w", err) + } + var pkt string + for _, line := range strings.Split(conv, "\n") { + if strings.HasPrefix(line, "packet-hex") { + pkt = strings.TrimSpace(strings.TrimPrefix(line, "packet-hex")) + } + } + if pkt == "" { + return fmt.Errorf("no packet-hex in packetconv output") + } + return d.deliverToCbdc(ctx, p.sequence, pkt) +} + +// deliverToCbdc proves a message out of Besu at its current head and submits it +// on cbdc-node -- the shared tail of receives and acks. extra is passed through +// to qbftrelay (e.g. -as-ack -ack-hex ...). qbftrelay emits an UNSIGNED tx; +// hnld signs it. This daemon never holds the key. +func (d *driver) deliverToCbdc(ctx context.Context, seq uint64, pktHex string, extra ...string) error { + trusted, err := d.cbdcClientHeight(ctx) + if err != nil { + return err + } + head, err := d.besu.call(ctx, "eth_blockNumber", []any{}) + if err != nil { + return err + } + var hexHead string + _ = json.Unmarshal(head, &hexHead) + target, _ := strconv.ParseUint(strings.TrimPrefix(hexHead, "0x"), 16, 64) + + // alice's key gets applied to whatever sits at the unsigned path, so it + // must not be a predictable name in world-writable /tmp -- a local user + // could pre-create a symlink or swap the file between write and sign. A + // fresh 0700 dir per invocation closes that. + dir, err := os.MkdirTemp("", "corridord-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + unsigned := filepath.Join(dir, fmt.Sprintf("msg-%d.json", seq)) + args := []string{ + "run", "./cmd/qbftrelay", + "-besu-rpc", d.cfg.besuRPC, "-contract", d.cfg.router, "-client-id", d.cfg.cbdcCli, + "-packet-hex", pktHex, "-trusted-height", strconv.FormatUint(trusted, 10), + "-target-height", strconv.FormatUint(target, 10), + "-evm-chain-id", strconv.FormatUint(d.cfg.evmChain, 10), + "-signer", d.cfg.signer, "-out", unsigned, + } + args = append(args, extra...) + if _, err := run(ctx, "go", args...); err != nil { + return fmt.Errorf("qbftrelay: %w", err) + } + + signed := unsigned + ".signed" + if _, err := run(ctx, "./bin/hnld", "--home", d.cfg.cbdcHome, "tx", "sign", unsigned, + "--from", d.cfg.keyName, "--keyring-backend", "test", "--chain-id", d.cfg.cbdcChain, + "--output-document", signed); err != nil { + return fmt.Errorf("sign: %w", err) + } + out, err := run(ctx, "./bin/hnld", "--home", d.cfg.cbdcHome, "tx", "broadcast", signed, "--output", "json") + if err != nil { + return fmt.Errorf("broadcast: %w", err) + } + if strings.Contains(out, "\"code\":0") { + return nil + } + return fmt.Errorf("broadcast rejected: %s", strings.TrimSpace(out)) +} + +func (d *driver) cbdcClientHeight(ctx context.Context) (uint64, error) { + out, err := run(ctx, "./bin/hnld", "--home", d.cfg.cbdcHome, "q", "ibc", "client", "state", d.cfg.cbdcCli, "--output", "json") + if err != nil { + return 0, err + } + var st struct { + ClientState struct { + LatestHeight any `json:"latest_height"` + } `json:"client_state"` + } + if err := json.Unmarshal([]byte(out), &st); err != nil { + return 0, err + } + switch v := st.ClientState.LatestHeight.(type) { + case string: + return strconv.ParseUint(v, 10, 64) + case float64: + return uint64(v), nil + } + return 0, fmt.Errorf("cannot read client height") +} diff --git a/cmd/corridord/chains_test.go b/cmd/corridord/chains_test.go new file mode 100644 index 0000000..c708874 --- /dev/null +++ b/cmd/corridord/chains_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestStoreValueSet pins the fail-closed contract: only a code-0 response may +// answer "set" or "not set"; every failure shape must surface as an error, +// never as (false, nil) -- which outbound would read as "timed out, refunded" +// and permanently strand the escrow behind a live commitment. +func TestStoreValueSet(t *testing.T) { + cases := []struct { + name string + status int + body string + wantSet bool + wantErr string // substring the error must contain; "" means no error + }{ + {"value set", http.StatusOK, `{"result":{"response":{"code":0,"value":"aGVsbG8="}}}`, true, ""}, + {"legitimately not set", http.StatusOK, `{"result":{"response":{"code":0,"value":""}}}`, false, ""}, + {"abci error code", http.StatusOK, `{"result":{"response":{"code":6,"log":"unknown store: ibc","value":""}}}`, false, "code=6"}, + {"json-rpc error object", http.StatusInternalServerError, `{"jsonrpc":"2.0","id":-1,"error":{"code":-32603,"message":"Internal error"}}`, false, "HTTP 500"}, + {"plain 500", http.StatusInternalServerError, "internal server error", false, "HTTP 500"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + c := &cbdcRPC{rpc: srv.URL} + set, err := c.storeValueSet(context.Background(), commitmentPath("qbftclient-0", 1)) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if set != tc.wantSet { + t.Fatalf("set = %v, want %v", set, tc.wantSet) + } + return + } + if err == nil { + t.Fatalf("failure read as an answer: set=%v, err=nil; want error containing %q", set, tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q does not contain %q", err, tc.wantErr) + } + }) + } +} diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go new file mode 100644 index 0000000..11978a2 --- /dev/null +++ b/cmd/corridord/main.go @@ -0,0 +1,418 @@ +// Command corridord drives the cbdc-node <-> Besu corridor automatically. +// +// # SCOPE, AND WHAT THIS IS NOT +// +// DEC-18 names cosmos/ibc-relayer as the corridor driver and DEC-27 puts driver +// work in v2. This is neither: it is a first-party daemon for the v1 rig, built +// because the decided path needs three components that do not exist yet -- the +// cmd/qbftproofapi shim, proof-api running cosmos-to-eth in Attested mode, and +// relay-request submission in cbdc-issuer, since cosmos/ibc-relayer has no event +// loop of its own. +// +// Treat this as a rig tool. It is not a production relayer: no persistence, no +// retry budget, no fee management, no crash-resume. +// +// # KEY CUSTODY +// +// This process holds NO attestor key. It asks the sidecar to attest, and the +// sidecar independently verifies against cbdc-node before signing. It also holds +// no cbdc-node key: the inbound leg emits an UNSIGNED tx and invokes the chain's +// own tooling to sign it, which is the boundary DEC-7 draws -- nothing we ship +// signs anything. +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "time" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" +) + +type config struct { + besuRPC string + cbdcRPC string + attestor string + router string + lightCli string + cbdcCli string // qbftclient-0 + besuCli string // client-1 + evmChain uint64 + signer string + cbdcHome string + cbdcChain string + keyName string + senderPK string + interval time.Duration +} + +func main() { + cfg := config{} + flag.StringVar(&cfg.besuRPC, "besu-rpc", "http://127.0.0.1:8645", "Besu JSON-RPC") + flag.StringVar(&cfg.cbdcRPC, "cbdc-rpc", "http://127.0.0.1:26657", "cbdc-node Tendermint RPC") + flag.StringVar(&cfg.attestor, "attestor", "http://127.0.0.1:8090", "attestor sidecar") + flag.StringVar(&cfg.router, "router", "", "ICS26Router address on Besu") + flag.StringVar(&cfg.lightCli, "light-client", "", "AttestationLightClient address on Besu") + flag.StringVar(&cfg.cbdcCli, "cbdc-client", "qbftclient-0", "client id on cbdc-node") + flag.StringVar(&cfg.besuCli, "besu-client", "client-1", "client id on Besu") + flag.Uint64Var(&cfg.evmChain, "evm-chain-id", 5040000, "cbdc-node EVM chain id for tx encoding") + flag.StringVar(&cfg.signer, "signer", "", "bech32 signer on cbdc-node") + flag.StringVar(&cfg.cbdcHome, "cbdc-home", "", "hnld home directory") + flag.StringVar(&cfg.cbdcChain, "cbdc-chain-id", "cbdc-honduras_5040000-1", "cosmos chain id") + flag.StringVar(&cfg.keyName, "key-name", "alice", "hnld keyring key that signs inbound txs") + flag.StringVar(&cfg.senderPK, "besu-key", "", "Besu private key that pays gas (holds no attestor power)") + flag.DurationVar(&cfg.interval, "interval", 3*time.Second, "poll interval") + flag.Parse() + + if cfg.router == "" || cfg.lightCli == "" || cfg.signer == "" || cfg.cbdcHome == "" || cfg.senderPK == "" { + log.Fatal("required: -router -light-client -signer -cbdc-home -besu-key") + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + d := &driver{ + cfg: cfg, + besu: &besuClient{rpc: cfg.besuRPC}, + cbdc: &cbdcRPC{rpc: cfg.cbdcRPC}, + doneOut: map[uint64]bool{}, + doneIn: map[uint64]bool{}, + ackedOut: map[uint64]bool{}, + ackedIn: map[uint64]bool{}, + attested: map[uint64]bool{}, + } + + log.Printf("corridord: %s <-> %s", cfg.cbdcChain, cfg.besuRPC) + log.Printf(" inbound besu(%s) -> cbdc(%s) via QBFT light client + MPT proof", cfg.besuCli, cfg.cbdcCli) + log.Printf(" outbound cbdc(%s) -> besu(%s) via attestor sidecar at %s", cfg.cbdcCli, cfg.besuCli, cfg.attestor) + log.Printf(" holding no attestor key and no cbdc-node key") + + t := time.NewTicker(cfg.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + log.Print("shutting down") + return + case <-t.C: + d.tick(ctx) + } + } +} + +type driver struct { + cfg config + besu *besuClient + cbdc *cbdcRPC + + doneOut map[uint64]bool // cbdc-node sequences already delivered on Besu + doneIn map[uint64]bool // Besu sequences already delivered on cbdc-node + ackedOut map[uint64]bool // cbdc-node sequences whose Besu ack reached cbdc-node + ackedIn map[uint64]bool // Besu sequences whose cbdc-node ack reached Besu + attested map[uint64]bool // heights already pushed to the light client +} + +// tick returns nothing on purpose: a leg failing is logged and the next tick +// retries it. There is no error a caller could act on that this has not already +// handled by continuing. +func (d *driver) tick(ctx context.Context) { + if err := d.outbound(ctx); err != nil { + log.Printf("outbound: %v", err) + } + if err := d.inbound(ctx); err != nil { + log.Printf("inbound: %v", err) + } + // Acks run after deliveries: each ack pass depends on the receive the + // packet passes just made, so ordering them this way usually closes the + // loop within one tick instead of two. + if err := d.ackOutbound(ctx); err != nil { + log.Printf("ack-outbound: %v", err) + } + if err := d.ackInbound(ctx); err != nil { + log.Printf("ack-inbound: %v", err) + } +} + +// outbound moves cbdc-node -> Besu. There is no proof to fetch: the sidecar +// attests, and the light client checks signatures. +func (d *driver) outbound(ctx context.Context) error { + seqs, err := d.cbdc.allSentSequences(ctx, d.cfg.cbdcCli) + if err != nil { + return err + } + for _, seq := range seqs { + if d.doneOut[seq] { + continue + } + recvd, err := d.besu.packetReceived(ctx, d.cfg.router, d.cfg.besuCli, seq) + if err != nil { + // Genuinely fail closed: skip this tick rather than assume + // "not delivered", which is the redelivery loop. + return fmt.Errorf("receipt check for seq %d: %w", seq, err) + } + if recvd { + d.doneOut[seq] = true + continue + } + set, err := d.cbdc.commitmentSet(ctx, d.cfg.cbdcCli, seq) + if err != nil { + return fmt.Errorf("commitment check for seq %d: %w", seq, err) + } + if !set { + // No receipt on Besu AND no commitment here: MsgTimeout already + // refunded this packet. It is finished, not pending -- without + // this check the daemon retries it every tick forever, and the + // attestor (correctly) refuses each attempt because the + // commitment it would attest no longer exists. + d.doneOut[seq] = true + continue + } + h, err := d.cbdc.latestHeight(ctx) + if err != nil { + return err + } + // The commitment must be visible at the height we attest. + if err := d.advanceTo(ctx, h); err != nil { + return err + } + proof, err := d.askAttestor(ctx, "/attest/packet", map[string]any{"height": h, "sequences": []uint64{seq}}) + if err != nil { + return fmt.Errorf("attest packet %d: %w", seq, err) + } + packetHex, err := d.cbdc.packetHex(ctx, d.cfg.cbdcCli, seq) + if err != nil { + return fmt.Errorf("packet %d: %w", seq, err) + } + tuple, err := solidityTuple(packetHex) + if err != nil { + return fmt.Errorf("convert packet %d: %w", seq, err) + } + if err := d.besu.recvPacket(ctx, d.cfg.senderPK, d.cfg.router, tuple, proof, h); err != nil { + return fmt.Errorf("recvPacket %d: %w", seq, err) + } + d.doneOut[seq] = true + log.Printf("outbound: delivered cbdc packet seq=%d at height=%d", seq, h) + } + return nil +} + +// advanceTo pushes a state attestation for height h to the light client once +// per height. Everything proved by attestation -- packet membership, ack +// membership, receipt absence -- verifies against the trusted timestamp at its +// proof height, so this must land first. +func (d *driver) advanceTo(ctx context.Context, h uint64) error { + if d.attested[h] { + return nil + } + proof, err := d.askAttestor(ctx, "/attest/state", map[string]any{"height": h}) + if err != nil { + return fmt.Errorf("attest state %d: %w", h, err) + } + if err := d.besu.send(ctx, d.cfg.senderPK, d.cfg.lightCli, "updateClient(bytes)", proof); err != nil { + return fmt.Errorf("updateClient %d: %w", h, err) + } + d.attested[h] = true + log.Printf("light client advanced to cbdc height %d", h) + return nil +} + +// inbound moves Besu -> cbdc-node. Real MPT proofs, no attestation involved. +func (d *driver) inbound(ctx context.Context) error { + packets, err := d.besu.sendPackets(ctx, d.cfg.router, d.cfg.besuCli) + if err != nil { + return err + } + for _, p := range packets { + if d.doneIn[p.sequence] { + continue + } + got, err := d.cbdc.packetReceived(ctx, d.cfg.cbdcCli, p.sequence) + if err != nil { + return fmt.Errorf("receipt check for besu seq %d: %w", p.sequence, err) + } + if got { + d.doneIn[p.sequence] = true + continue + } + set, err := d.besu.commitmentSet(ctx, d.cfg.router, sendCommitmentKey(d.cfg.besuCli, p.sequence)) + if err != nil { + return fmt.Errorf("commitment check for besu seq %d: %w", p.sequence, err) + } + if !set { + // Mirror of the outbound check: no receipt here and no send + // commitment on Besu means timeoutPacket already refunded it. + // Without this, the daemon re-submits a dead packet every tick + // and cbdc-node rejects each with "timeout elapsed". + d.doneIn[p.sequence] = true + continue + } + if err := d.relayInbound(ctx, p); err != nil { + return fmt.Errorf("relay besu seq %d: %w", p.sequence, err) + } + d.doneIn[p.sequence] = true + log.Printf("inbound: delivered besu packet seq=%d", p.sequence) + } + return nil +} + +// ackOutbound returns acknowledgements for cbdc->besu packets. The ack was +// written on Besu at receive time, but cbdc-node's send commitment stays set -- +// with the escrow behind it -- until a MsgAcknowledgement delivers the ack +// back. Ordinary MPT proof of the ack path, same trust path as inbound. +func (d *driver) ackOutbound(ctx context.Context) error { + acks, err := d.besu.writeAcks(ctx, d.cfg.router, d.cfg.besuCli) + if err != nil { + return err + } + for _, wa := range acks { + if d.ackedOut[wa.sequence] { + continue + } + set, err := d.cbdc.commitmentSet(ctx, d.cfg.cbdcCli, wa.sequence) + if err != nil { + // Fail closed: without the commitment we cannot tell whether the + // ack is still needed, and guessing either way misbehaves. + return fmt.Errorf("commitment check for seq %d: %w", wa.sequence, err) + } + if !set { + d.ackedOut[wa.sequence] = true + continue + } + pkt, err := d.cbdc.packetHex(ctx, d.cfg.cbdcCli, wa.sequence) + if err != nil { + return fmt.Errorf("packet %d: %w", wa.sequence, err) + } + if err := d.deliverToCbdc(ctx, wa.sequence, pkt, "-as-ack", "-ack-hex", hex.EncodeToString(wa.ack)); err != nil { + return fmt.Errorf("relay ack for seq %d: %w", wa.sequence, err) + } + d.ackedOut[wa.sequence] = true + log.Printf("ack-outbound: cleared cbdc commitment seq=%d", wa.sequence) + } + return nil +} + +// ackInbound returns acknowledgements for besu->cbdc packets: cbdc-node wrote +// the ack when it received, and the send commitment on Besu -- the entry +// holding the escrow -- stays set until ackPacket sees a membership proof of +// that ack. This is the leg whose absence left Besu commitments set for +// packets cbdc-node had already received AND acknowledged. +func (d *driver) ackInbound(ctx context.Context) error { + evs, err := d.cbdc.ackEvents(ctx, d.cfg.cbdcCli) + if err != nil { + return err + } + for seq, ev := range evs { + if d.ackedIn[seq] { + continue + } + set, err := d.besu.commitmentSet(ctx, d.cfg.router, sendCommitmentKey(d.cfg.besuCli, seq)) + if err != nil { + return fmt.Errorf("commitment check for besu seq %d: %w", seq, err) + } + if !set { + // Already acked or timed out; either way the escrow entry is gone. + d.ackedIn[seq] = true + continue + } + // The event carries the protobuf Acknowledgement WRAPPER; the router + // expects the raw app ack and recomputes the commitment from it, so + // submitting the wrong layer fails verification rather than clearing. + rawAck, err := appAck(ev.ackHex) + if err != nil { + return fmt.Errorf("ack for seq %d: %w", seq, err) + } + h, err := d.cbdc.latestHeight(ctx) + if err != nil { + return err + } + // The ack must be visible at the height we attest. + if err := d.advanceTo(ctx, h); err != nil { + return err + } + proof, err := d.askAttestor(ctx, "/attest/ack", map[string]any{"height": h, "sequences": []uint64{seq}}) + if err != nil { + return fmt.Errorf("attest ack %d: %w", seq, err) + } + tuple, err := solidityTuple(ev.packetHex) + if err != nil { + return fmt.Errorf("convert packet %d: %w", seq, err) + } + if err := d.besu.ackPacket(ctx, d.cfg.senderPK, d.cfg.router, tuple, rawAck, proof, h); err != nil { + return fmt.Errorf("ackPacket %d: %w", seq, err) + } + d.ackedIn[seq] = true + log.Printf("ack-inbound: cleared besu commitment seq=%d at height=%d", seq, h) + } + return nil +} + +// appAck unwraps the protobuf Acknowledgement that cbdc-node's +// write_acknowledgement event carries into the single raw app ack +// ICS26Router.ackPacket expects. The distinction matters: the on-chain ack +// commitment hashes the raw acks, so the wrapper bytes would never verify. +func appAck(ackHex string) ([]byte, error) { + raw, err := hex.DecodeString(ackHex) + if err != nil { + return nil, fmt.Errorf("bad ack hex: %w", err) + } + var ack channeltypesv2.Acknowledgement + if err := ack.Unmarshal(raw); err != nil { + return nil, fmt.Errorf("unmarshal acknowledgement: %w", err) + } + if len(ack.AppAcknowledgements) != 1 { + return nil, fmt.Errorf("expected exactly 1 app ack (single-payload rig), got %d", len(ack.AppAcknowledgements)) + } + return ack.AppAcknowledgements[0], nil +} + +func (d *driver) askAttestor(ctx context.Context, path string, body map[string]any) ([]byte, error) { + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.cfg.attestor+path, bytes.NewReader(b)) + if err != nil { + return nil, err + } + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // io.ReadAll, not a single Read: a bare Read returns whatever one + // chunk happens to hold, so a refusal explaining WHY the attestor + // declined could be truncated to nothing useful. Capped so a + // misbehaving endpoint cannot stream unboundedly. + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + return nil, fmt.Errorf("attestor refused (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + var out struct { + Proof string `json:"proof"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return hex.DecodeString(strings.TrimPrefix(out.Proof, "0x")) +} + +// run shells out. Used for the two operations this daemon deliberately does not +// own: building the MPT proof (qbftrelay) and signing on cbdc-node (hnld). +func run(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Run(); err != nil { + return out.String(), fmt.Errorf("%s: %v: %s", name, err, strings.TrimSpace(errb.String())) + } + return out.String(), nil +} diff --git a/cmd/packetconv/main.go b/cmd/packetconv/main.go new file mode 100644 index 0000000..bdb3d8b --- /dev/null +++ b/cmd/packetconv/main.go @@ -0,0 +1,174 @@ +// Command packetconv translates a solidity-ibc-eureka SendPacket event into the +// protobuf channeltypesv2.Packet that cbdc-node's MsgRecvPacket carries. +// +// It exists to answer one question the rig has to settle before a packet can +// cross: do the Solidity and Go packet-commitment encodings agree byte for byte? +// The commitment sitting in Besu storage was computed by ICS24Host; the one +// cbdc-node checks the proof against is computed by channeltypesv2.CommitPacket. +// If they differ by a single byte the corridor cannot work, and nothing else in +// the stack would tell you why. +package main + +import ( + "context" + "encoding/hex" + "flag" + "fmt" + "os" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" +) + +const sendPacketABI = `[{ + "type":"event","name":"SendPacket","anonymous":false, + "inputs":[ + {"name":"clientId","type":"string","indexed":true}, + {"name":"sequence","type":"uint256","indexed":true}, + {"name":"packet","type":"tuple","indexed":false,"components":[ + {"name":"sequence","type":"uint64"}, + {"name":"sourceClient","type":"string"}, + {"name":"destClient","type":"string"}, + {"name":"timeoutTimestamp","type":"uint64"}, + {"name":"payloads","type":"tuple[]","components":[ + {"name":"sourcePort","type":"string"}, + {"name":"destPort","type":"string"}, + {"name":"version","type":"string"}, + {"name":"encoding","type":"string"}, + {"name":"value","type":"bytes"} + ]} + ]} + ]}]` + +type solPayload struct { + SourcePort string + DestPort string + Version string + Encoding string + Value []byte +} + +type solPacket struct { + Sequence uint64 + SourceClient string + DestClient string + TimeoutTimestamp uint64 + Payloads []solPayload +} + +func main() { + rpcURL := flag.String("besu-rpc", "http://127.0.0.1:8745", "Besu JSON-RPC endpoint") + txHash := flag.String("tx", "", "transaction hash of the sendTransfer") + onchain := flag.String("commitment", "", "commitment read from Besu storage, to cross-check") + toSolidity := flag.String("to-solidity", "", "reverse direction: given a protobuf packet hex, print the Solidity tuple for cast. Used to submit a cbdc-node packet to the counterparty's ICS26Router") + flag.Parse() + + if *toSolidity != "" { + bz, err := hex.DecodeString(strings.TrimPrefix(*toSolidity, "0x")) + must(err, "decode packet hex") + var pk channeltypesv2.Packet + must(pk.Unmarshal(bz), "unmarshal packet") + // The Solidity Packet tuple, in the field order IICS26RouterMsgs.Packet + // declares: (sequence, sourceClient, destClient, timeoutTimestamp, payloads[]). + var pls []string + for _, pl := range pk.Payloads { + pls = append(pls, fmt.Sprintf(`("%s","%s","%s","%s",0x%s)`, + pl.SourcePort, pl.DestinationPort, pl.Version, pl.Encoding, hex.EncodeToString(pl.Value))) + } + fmt.Printf("(%d,\"%s\",\"%s\",%d,[%s])\n", + pk.Sequence, pk.SourceClient, pk.DestinationClient, pk.TimeoutTimestamp, strings.Join(pls, ",")) + return + } + + if *txHash == "" { + fmt.Fprintln(os.Stderr, "--tx is required") + os.Exit(1) + } + + ctx := context.Background() + client, err := ethclient.DialContext(ctx, *rpcURL) + must(err, "dial") + defer client.Close() + + receipt, err := client.TransactionReceipt(ctx, common.HexToHash(*txHash)) + must(err, "receipt") + + parsed, err := abi.JSON(strings.NewReader(sendPacketABI)) + must(err, "parse abi") + topic := parsed.Events["SendPacket"].ID + + var sol solPacket + found := false + for _, lg := range receipt.Logs { + if len(lg.Topics) == 0 || lg.Topics[0] != topic { + continue + } + out, err := parsed.Unpack("SendPacket", lg.Data) + must(err, "unpack") + // The single non-indexed argument is the packet tuple; go-ethereum + // decodes it into an anonymous struct, so re-marshal through the ABI + // argument set to land it in ours. + err = parsed.Events["SendPacket"].Inputs.NonIndexed().Copy(&struct { + Packet *solPacket + }{Packet: &sol}, out) + must(err, "copy") + found = true + break + } + if !found { + fmt.Fprintln(os.Stderr, "no SendPacket event in that transaction") + //nolint:gocritic // exiting main; the OS reclaims what the defer would have released + os.Exit(1) + } + + packet := channeltypesv2.Packet{ + Sequence: sol.Sequence, + SourceClient: sol.SourceClient, + DestinationClient: sol.DestClient, + TimeoutTimestamp: sol.TimeoutTimestamp, + } + for _, p := range sol.Payloads { + packet.Payloads = append(packet.Payloads, channeltypesv2.Payload{ + SourcePort: p.SourcePort, + DestinationPort: p.DestPort, + Version: p.Version, + Encoding: p.Encoding, + Value: p.Value, + }) + } + + bz, err := packet.Marshal() + must(err, "marshal packet") + + goCommitment := channeltypesv2.CommitPacket(packet) + + fmt.Printf("sequence %d\n", packet.Sequence) + fmt.Printf("source -> dest %s -> %s\n", packet.SourceClient, packet.DestinationClient) + fmt.Printf("timeout %d\n", packet.TimeoutTimestamp) + for i, p := range packet.Payloads { + fmt.Printf("payload[%d] %s/%s version=%s encoding=%s value=%d bytes\n", + i, p.SourcePort, p.DestinationPort, p.Version, p.Encoding, len(p.Value)) + } + fmt.Printf("\ngo commitment 0x%s\n", hex.EncodeToString(goCommitment)) + if *onchain != "" { + want := strings.TrimPrefix(strings.ToLower(*onchain), "0x") + if want == hex.EncodeToString(goCommitment) { + fmt.Printf("besu commitment 0x%s ✅ MATCH — Solidity and Go agree\n", want) + } else { + fmt.Printf("besu commitment 0x%s ❌ MISMATCH\n", want) + os.Exit(2) + } + } + fmt.Printf("\npacket-hex %s\n", hex.EncodeToString(bz)) +} + +func must(err error, what string) { + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", what, err) + os.Exit(1) + } +} diff --git a/cmd/qbftaggregator/main.go b/cmd/qbftaggregator/main.go new file mode 100644 index 0000000..429777e --- /dev/null +++ b/cmd/qbftaggregator/main.go @@ -0,0 +1,248 @@ +// Command qbftaggregator fans one attestation request out to N independent +// qbftattestor sidecars and merges their signatures into one proof. +// +// # WHY IT IS A SEPARATE PROCESS +// +// DEC-31 puts the attestor set at 3-of-4 from v2, drawn from cbdc-node's +// validator set. Nothing in the corridor could produce a 3-of-4 proof: the wire +// format has always carried `repeated bytes signatures`, but qbftattestor signs +// with its own single local key and returns a one-element array. The service is +// called AggregatorService because upstream expects an aggregator in FRONT of +// the attestors; this is that aggregator. +// +// It implements the same AggregatorService interface it consumes, so +// qbftproofapi points its -attestor-grpc at this process instead of at a single +// sidecar and needs no change at all. +// +// # WHAT IT MUST NOT DO +// +// It holds no key and verifies no chain state. It is a fan-out and a merge. +// Every signature it returns was produced by an attestor that independently +// read cbdc-node -- putting verification here would recreate the exact hole the +// attestor's "never sign what you are told" rule exists to close. +// +// # THE INVARIANT THAT MAKES THE MERGE LEGAL +// +// AttestationLightClient recovers EVERY signature against ONE digest computed +// from ONE attestationData blob. Signatures over different blobs cannot be +// merged -- they would each be individually valid and collectively meaningless. +// So the attested bytes from all backends must be byte-identical, and a +// divergence is refused rather than resolved. Divergence means two attestors +// genuinely disagree about cbdc-node's state at that height, which is a +// condition to alert on, not to paper over by picking a majority blob. +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "log" + "net" + "strings" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + pb "github.com/peersyst/cbdc-node/x/qbftclient/attestor/aggregatorpb" +) + +type backend struct { + addr string + client pb.AggregatorServiceClient +} + +type aggregator struct { + backends []backend + minSigs int + timeout time.Duration +} + +func main() { + var ( + listen = flag.String("listen", "127.0.0.1:8091", "address to serve AggregatorService on (what qbftproofapi dials)") + attestors = flag.String("attestors", "", "comma-separated qbftattestor gRPC addresses (required)") + minSigs = flag.Int("min-sigs", 0, "signatures required to return a proof; must equal the light client's minRequiredSigs (required)") + timeout = flag.Duration("timeout", 10*time.Second, "per-attestor deadline") + ) + flag.Parse() + + if *attestors == "" || *minSigs <= 0 { + log.Fatal("required: -attestors -min-sigs") + } + addrs, err := parseAttestors(*attestors) + if err != nil { + log.Fatal(err) + } + if *minSigs > len(addrs) { + log.Fatalf("-min-sigs %d exceeds the %d attestors configured: this can never produce a proof", *minSigs, len(addrs)) + } + // Not an error, but it is the whole point of the exercise: a threshold that + // any single attestor satisfies alone is 1-of-1 with extra hops. + if *minSigs == 1 && len(addrs) > 1 { + log.Printf("WARNING: -min-sigs 1 with %d attestors -- any one of them can produce a valid proof unaided", len(addrs)) + } + + a := &aggregator{minSigs: *minSigs, timeout: *timeout} + for _, addr := range addrs { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatalf("attestor %s: %v", addr, err) + } + a.backends = append(a.backends, backend{addr: addr, client: pb.NewAggregatorServiceClient(conn)}) + } + + lis, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatalf("listen %s: %v", *listen, err) + } + srv := grpc.NewServer() + pb.RegisterAggregatorServiceServer(srv, a) + log.Printf("qbftaggregator on %s: %d-of-%d over %s", *listen, *minSigs, len(a.backends), strings.Join(addrs, ",")) + log.Fatal(srv.Serve(lis)) +} + +// parseAttestors splits and trims the -attestors list, rejecting empty and +// duplicate entries. A repeated address is fatal rather than deduped: the +// operator who wrote it believes they have more attestors than they do, and +// the threshold below would count one key several times toward m-of-n. +func parseAttestors(csv string) ([]string, error) { + addrs := strings.Split(csv, ",") + seen := make(map[string]bool, len(addrs)) + for i, addr := range addrs { + addr = strings.TrimSpace(addr) + if addr == "" { + return nil, fmt.Errorf("-attestors %q contains an empty address", csv) + } + if seen[addr] { + return nil, fmt.Errorf("attestor %s is listed more than once: every entry must be a distinct attestor, or one key counts twice toward the threshold", addr) + } + seen[addr] = true + addrs[i] = addr + } + return addrs, nil +} + +type result struct { + addr string + resp *pb.GetAttestationsResponse + err error +} + +// GetAttestations asks every backend the SAME question and merges the answers. +// +// The request is forwarded verbatim: each attestor must attest the same height +// over the same packet paths in the same ORDER, because the packet attestation +// is an ABI-encoded array and its encoding is order-sensitive. Rebuilding the +// list per backend would produce signatures over different digests that look +// individually valid and merge into nothing. +func (a *aggregator) GetAttestations(ctx context.Context, req *pb.GetAttestationsRequest) (*pb.GetAttestationsResponse, error) { + if req.GetHeight() == 0 { + return nil, fmt.Errorf("height is required") + } + + ctx, cancel := context.WithTimeout(ctx, a.timeout) + defer cancel() + + results := make([]result, len(a.backends)) + var wg sync.WaitGroup + for i, b := range a.backends { + wg.Add(1) + go func(i int, b backend) { + defer wg.Done() + resp, err := b.client.GetAttestations(ctx, req) + results[i] = result{addr: b.addr, resp: resp, err: err} + }(i, b) + } + wg.Wait() + + state, err := a.merge(results, func(r *pb.GetAttestationsResponse) *pb.AggregatedAttestation { + return r.GetStateAttestation() + }, "state") + if err != nil { + return nil, err + } + + out := &pb.GetAttestationsResponse{StateAttestation: state} + + // A packet attestation is absent when the caller asked for no packets, and + // absent is not the same as unavailable: only merge when one was requested. + if len(req.GetPackets()) > 0 { + packet, err := a.merge(results, func(r *pb.GetAttestationsResponse) *pb.AggregatedAttestation { + return r.GetPacketAttestation() + }, "packet") + if err != nil { + return nil, err + } + out.PacketAttestation = packet + } + + return out, nil +} + +// merge collects one attestation kind across all backends, refusing on any +// disagreement about the attested bytes and requiring minSigs signatures. +func (a *aggregator) merge( + results []result, + pick func(*pb.GetAttestationsResponse) *pb.AggregatedAttestation, + kind string, +) (*pb.AggregatedAttestation, error) { + var merged *pb.AggregatedAttestation + sigs := make([][]byte, 0, len(results)) + var refused []string + + for _, r := range results { + if r.err != nil { + // One attestor being down is survivable up to the threshold, so it + // is logged and counted, never fatal on its own. + refused = append(refused, fmt.Sprintf("%s: %v", r.addr, r.err)) + continue + } + att := pick(r.resp) + if att == nil || len(att.GetAttestedData()) == 0 { + refused = append(refused, fmt.Sprintf("%s: no %s attestation returned", r.addr, kind)) + continue + } + if merged == nil { + merged = &pb.AggregatedAttestation{ + Height: att.GetHeight(), + Timestamp: att.Timestamp, + AttestedData: att.GetAttestedData(), + } + } else if !bytes.Equal(merged.AttestedData, att.GetAttestedData()) { + // Terminal, and deliberately not resolved by majority: two + // attestors reading the same chain at the same height MUST produce + // identical bytes. Different bytes mean one of them is reading a + // different chain, or a re-genesised one -- and signing past that + // is how a light client gets permanently frozen. + return nil, fmt.Errorf( + "%s attestation MISMATCH at height %d: %s returned different attested bytes than the first backend. "+ + "Two attestors disagree about cbdc-node's state; do not retry, investigate which one is wrong", + kind, att.GetHeight(), r.addr) + } + // A compliant qbftattestor holds one key and returns exactly one + // signature. More means this backend is not an attestor sidecar (a + // chained aggregator, most likely) and cannot be counted as one + // distinct signer; it is refused like any other bad response, so one + // misconfigured entry does not sink a quorum the rest can meet. + if n := len(att.GetSignatures()); n != 1 { + refused = append(refused, fmt.Sprintf("%s: returned %d signatures, a qbftattestor returns exactly 1", r.addr, n)) + continue + } + sigs = append(sigs, att.GetSignatures()[0]) + } + + if len(sigs) < a.minSigs { + return nil, fmt.Errorf( + "%s attestation has signatures from %d attestor(s), need %d: %s", + kind, len(sigs), a.minSigs, strings.Join(refused, "; ")) + } + if len(refused) > 0 { + log.Printf("%s height=%d: proceeding with signatures from %d of %d attestors; refusals: %s", + kind, merged.GetHeight(), len(sigs), len(a.backends), strings.Join(refused, "; ")) + } + merged.Signatures = sigs + return merged, nil +} diff --git a/cmd/qbftaggregator/main_test.go b/cmd/qbftaggregator/main_test.go new file mode 100644 index 0000000..d434e60 --- /dev/null +++ b/cmd/qbftaggregator/main_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "errors" + "strings" + "testing" + + pb "github.com/peersyst/cbdc-node/x/qbftclient/attestor/aggregatorpb" +) + +func att(data string, sig string) *pb.GetAttestationsResponse { + return &pb.GetAttestationsResponse{ + StateAttestation: &pb.AggregatedAttestation{ + Height: 7, + AttestedData: []byte(data), + Signatures: [][]byte{[]byte(sig)}, + }, + } +} + +func pickState(r *pb.GetAttestationsResponse) *pb.AggregatedAttestation { + return r.GetStateAttestation() +} + +func TestMerge_CollectsSignaturesWhenAllAgree(t *testing.T) { + a := &aggregator{minSigs: 3} + got, err := a.merge([]result{ + {addr: "a", resp: att("same", "sig-a")}, + {addr: "b", resp: att("same", "sig-b")}, + {addr: "c", resp: att("same", "sig-c")}, + {addr: "d", resp: att("same", "sig-d")}, + }, pickState, "state") + if err != nil { + t.Fatalf("merge: %v", err) + } + if len(got.Signatures) != 4 { + t.Fatalf("want 4 signatures, got %d", len(got.Signatures)) + } + if string(got.AttestedData) != "same" { + t.Fatalf("attested data not preserved: %q", got.AttestedData) + } +} + +func TestMerge_ToleratesFailuresUpToThreshold(t *testing.T) { + a := &aggregator{minSigs: 3} + got, err := a.merge([]result{ + {addr: "a", resp: att("same", "sig-a")}, + {addr: "b", err: errors.New("connection refused")}, + {addr: "c", resp: att("same", "sig-c")}, + {addr: "d", resp: att("same", "sig-d")}, + }, pickState, "state") + if err != nil { + t.Fatalf("one backend down must not fail a 3-of-4: %v", err) + } + if len(got.Signatures) != 3 { + t.Fatalf("want 3 signatures, got %d", len(got.Signatures)) + } +} + +func TestMerge_RefusesBelowThreshold(t *testing.T) { + a := &aggregator{minSigs: 3} + _, err := a.merge([]result{ + {addr: "a", resp: att("same", "sig-a")}, + {addr: "b", err: errors.New("connection refused")}, + {addr: "c", err: errors.New("connection refused")}, + {addr: "d", resp: att("same", "sig-d")}, + }, pickState, "state") + if err == nil { + t.Fatal("2 signatures must not satisfy a 3-of-4") + } + // The refusals belong in the error: a threshold failure is unactionable + // without knowing which backends were unreachable. + if !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("error should name the failing backends, got: %v", err) + } +} + +func TestMerge_RefusesDivergentAttestedBytes(t *testing.T) { + a := &aggregator{minSigs: 2} + // Two attestors reading the same chain at the same height cannot disagree + // about the bytes. Merging signatures over different blobs would produce a + // proof whose signatures each verify against a different digest -- i.e. + // none of them against the one the contract computes. + _, err := a.merge([]result{ + {addr: "a", resp: att("height-7-ts-100", "sig-a")}, + {addr: "b", resp: att("height-7-ts-999", "sig-b")}, + }, pickState, "state") + if err == nil { + t.Fatal("divergent attested bytes must be refused, not merged") + } + if !strings.Contains(err.Error(), "MISMATCH") { + t.Fatalf("divergence should be reported as a mismatch, got: %v", err) + } +} + +func TestMerge_DoesNotResolveDivergenceByMajority(t *testing.T) { + a := &aggregator{minSigs: 2} + // Three agree and one differs. It is tempting to drop the outlier and + // proceed -- but an attestor reading a different chain is a freeze risk + // that must surface, not a vote to be outnumbered. + _, err := a.merge([]result{ + {addr: "a", resp: att("same", "sig-a")}, + {addr: "b", resp: att("same", "sig-b")}, + {addr: "c", resp: att("same", "sig-c")}, + {addr: "d", resp: att("different", "sig-d")}, + }, pickState, "state") + if err == nil { + t.Fatal("a majority must not silently outvote a divergent attestor") + } +} + +func multiSigAtt(data string, sigs ...string) *pb.GetAttestationsResponse { + raw := make([][]byte, len(sigs)) + for i, s := range sigs { + raw[i] = []byte(s) + } + return &pb.GetAttestationsResponse{ + StateAttestation: &pb.AggregatedAttestation{ + Height: 7, + AttestedData: []byte(data), + Signatures: raw, + }, + } +} + +func TestParseAttestors_RejectsDuplicates(t *testing.T) { + // A repeated address would let one key count several times toward m-of-n. + // The operator who typed it believes they have four attestors; telling + // them beats silently running a weaker quorum than they configured. + _, err := parseAttestors("a:8081,b:8082,a:8081") + if err == nil { + t.Fatal("a repeated attestor address must be fatal, not deduped") + } +} + +func TestParseAttestors_WhitespaceDoesNotDisguiseADuplicate(t *testing.T) { + // " a" and "a" dial the same backend; comparison must happen after trimming. + _, err := parseAttestors("a:8081, a:8081") + if err == nil { + t.Fatal("whitespace variants of the same address are still a duplicate") + } +} + +func TestParseAttestors_RejectsEmptyEntries(t *testing.T) { + // "a,b," splits into three entries; an empty one must not inflate the + // attestor count the -min-sigs check runs against. + _, err := parseAttestors("a:8081,b:8082,") + if err == nil { + t.Fatal("an empty entry must be rejected, not counted as an attestor") + } +} + +func TestParseAttestors_TrimsAndPreservesDistinctList(t *testing.T) { + addrs, err := parseAttestors(" a:8081, b:8082 ,c:8083") + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(addrs) != 3 || addrs[0] != "a:8081" || addrs[1] != "b:8082" || addrs[2] != "c:8083" { + t.Fatalf("want [a:8081 b:8082 c:8083], got %v", addrs) + } +} + +func TestMerge_OneBackendCannotMeetTheThresholdAlone(t *testing.T) { + a := &aggregator{minSigs: 3} + // One backend hands back three signatures in a single response. Whatever + // produced them, it is ONE backend: the threshold counts distinct + // attestors, and a compliant qbftattestor returns exactly one signature. + _, err := a.merge([]result{ + {addr: "a", resp: multiSigAtt("same", "sig-1", "sig-2", "sig-3")}, + }, pickState, "state") + if err == nil { + t.Fatal("three signatures from one backend must not satisfy a 3-of-N") + } +} + +func TestMerge_RefusesMultiSignatureBackendButKeepsQuorum(t *testing.T) { + a := &aggregator{minSigs: 2} + // The anomalous backend is refused like an unreachable one -- logged and + // excluded -- while the healthy majority still meets the threshold. + got, err := a.merge([]result{ + {addr: "a", resp: multiSigAtt("same", "sig-1", "sig-2")}, + {addr: "b", resp: att("same", "sig-b")}, + {addr: "c", resp: att("same", "sig-c")}, + }, pickState, "state") + if err != nil { + t.Fatalf("two healthy backends still meet a 2-of-3: %v", err) + } + if len(got.Signatures) != 2 { + t.Fatalf("want 2 signatures with the anomalous backend excluded, got %d", len(got.Signatures)) + } +} diff --git a/cmd/qbftattestor/attestation_service.go b/cmd/qbftattestor/attestation_service.go new file mode 100644 index 0000000..2af6d86 --- /dev/null +++ b/cmd/qbftattestor/attestation_service.go @@ -0,0 +1,152 @@ +package main + +// AttestationService: the interface cosmos/ibc-attestor serves natively, served +// here too so the two sidecars are interchangeable. +// +// WHY THIS EXISTS +// +// cmd/qbftproofapi speaks ONLY this protocol. If the first-party sidecar spoke +// only AggregatorService, then adopting upstream would be a code change and +// backing the adoption out would be a second code change -- the second one made +// during an incident, which is the worst time to edit a money path. With both +// sidecars serving this, cutover and rollback are both a change of address. +// +// The security discipline is unchanged from the HTTP and aggregator paths, and +// tightened in one respect: the caller no longer supplies the ICS-24 path. It +// supplies the PACKET, and the path is derived here from the packet plus the +// requested CommitmentType (see PathForCommitmentType). A caller can therefore +// no longer choose which key is read, only which packet is asked about. + +import ( + "context" + "fmt" + "log" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" + apb "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" +) + +// attestationServer adapts the sidecar to ibc_attestor.AttestationService. +type attestationServer struct { + s *server +} + +// StateAttestation signs (height, timestamp) with the timestamp READ FROM THE +// CHAIN. This is the only RPC here that can freeze the light client, so it is +// the only one behind the durable guard. +func (a *attestationServer) StateAttestation(ctx context.Context, req *apb.StateAttestationRequest) (*apb.StateAttestationResponse, error) { + height := req.GetHeight() + if height == 0 { + return nil, fmt.Errorf("height is required") + } + + ts, err := a.s.chain.blockTimeSeconds(ctx, height) + if err != nil { + return nil, fmt.Errorf("cannot verify height %d: %w", height, err) + } + // Durable before signing. Two different timestamps for one height is the + // only path to a terminal freeze, and there is no unfreeze. + if err := a.s.guard(height, ts); err != nil { + return nil, err + } + + data, err := attestor.EncodeState(height, ts) + if err != nil { + return nil, err + } + sig, err := attestor.Sign(a.s.key, attestor.Digest(data, attestor.TagState)) + if err != nil { + return nil, err + } + + log.Printf("grpc: attested state height=%d ts=%d", height, ts) + return &apb.StateAttestationResponse{Attestation: &apb.Attestation{ + Height: height, + Timestamp: &ts, + AttestedData: data, + Signature: sig, + }}, nil +} + +// PacketAttestation signs a claim about a set of packets at a height. The kind +// of claim is explicit in the request rather than implied by the key, which is +// what lets membership and NON-membership share one call. +func (a *attestationServer) PacketAttestation(ctx context.Context, req *apb.PacketAttestationRequest) (*apb.PacketAttestationResponse, error) { + height := req.GetHeight() + if height == 0 { + return nil, fmt.Errorf("height is required") + } + if len(req.GetPackets()) == 0 { + return nil, fmt.Errorf("at least one packet is required") + } + kind := attestor.CommitmentKind(req.GetCommitmentType()) + + // The block must exist on our node before its state is worth asking about. + // For a timeout this also tells the caller the time the router will compare + // against the packet's deadline, so a premature timeout is visible here + // rather than as a wasted on-chain revert. + ts, err := a.s.chain.blockTimeSeconds(ctx, height) + if err != nil { + return nil, fmt.Errorf("cannot verify height %d: %w", height, err) + } + + compacts := make([]attestor.PacketCompact, 0, len(req.GetPackets())) + for i, raw := range req.GetPackets() { + pk, err := attestor.DecodePacket(raw) + if err != nil { + return nil, fmt.Errorf("packet %d: %w", i, err) + } + path, err := attestor.PathForCommitmentType(pk, kind) + if err != nil { + return nil, err + } + + if kind == attestor.CommitmentKindReceipt { + // Non-membership. Refuse if a value is actually there: attesting + // absence of a receipt that exists releases escrow for a packet + // that WAS delivered. + if err := a.s.chain.provenAbsent(ctx, path, height); err != nil { + return nil, fmt.Errorf("REFUSING to attest absence for packet seq %d at height %d: %w", pk.Sequence, height, err) + } + // Commitment stays zero: {pathHash, bytes32(0)} is exactly what + // AttestationLightClient.verifyNonMembership demands. + compacts = append(compacts, attestor.PacketCompact{Path: keccakPath(path)}) + continue + } + + commitment, err := a.s.chain.commitment(ctx, path, height) + if err != nil { + return nil, fmt.Errorf("cannot verify packet seq %d at height %d: %w", pk.Sequence, height, err) + } + compacts = append(compacts, attestor.PacketCompact{ + Path: keccakPath(path), + Commitment: commitment, + }) + } + + data, err := attestor.EncodePackets(height, compacts) + if err != nil { + return nil, err + } + sig, err := attestor.Sign(a.s.key, attestor.Digest(data, attestor.TagPacket)) + if err != nil { + return nil, err + } + + log.Printf("grpc: attested %d packet(s) kind=%d at height=%d ts=%d", len(compacts), kind, height, ts) + return &apb.PacketAttestationResponse{Attestation: &apb.Attestation{ + Height: height, + AttestedData: data, + Signature: sig, + }}, nil +} + +// LatestHeight reports our own node's tip, so a caller need not carry a second +// connection to cbdc-node just to pick a height to attest at. +func (a *attestationServer) LatestHeight(ctx context.Context, _ *apb.LatestHeightRequest) (*apb.LatestHeightResponse, error) { + h, err := a.s.chain.latestHeight(ctx) + if err != nil { + return nil, fmt.Errorf("latest height: %w", err) + } + return &apb.LatestHeightResponse{Height: h}, nil +} diff --git a/cmd/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go new file mode 100644 index 0000000..bbbc5b8 --- /dev/null +++ b/cmd/qbftattestor/cbdc.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// cbdcClient is the attestor's OWN view of cbdc-node. It deliberately shares no +// code path with the relayer: the point of the sidecar is that its answers come +// from its own query, not from whoever is asking it to sign. +type cbdcClient struct { + rpc string +} + +func (c *cbdcClient) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.rpc+path, nil) + if err != nil { + return err + } + cl := &http.Client{Timeout: 10 * time.Second} + resp, err := cl.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("rpc %s: status %d", path, resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// blockTimeSeconds returns the consensus timestamp of a block, in unix seconds. +// +// Seconds, not nanoseconds: the contract stores and compares seconds, and the +// v1 nanosecond convention is a documented trap on this corridor. +func (c *cbdcClient) blockTimeSeconds(ctx context.Context, height uint64) (uint64, error) { + var out struct { + Result struct { + Block struct { + Header struct { + Time string `json:"time"` + Height string `json:"height"` + } `json:"header"` + } `json:"block"` + } `json:"result"` + } + if err := c.get(ctx, fmt.Sprintf("/block?height=%d", height), &out); err != nil { + return 0, err + } + if out.Result.Block.Header.Time == "" { + return 0, fmt.Errorf("height %d not found on this node", height) + } + t, err := time.Parse(time.RFC3339Nano, out.Result.Block.Header.Time) + if err != nil { + return 0, fmt.Errorf("parse block time: %w", err) + } + // Refused rather than widened: a pre-epoch block time would wrap into an + // enormous uint64 and be SIGNED as this height's timestamp. The light client + // stores a height's timestamp forever and freezes on seeing a second, so a + // wrapped value here is not recoverable. + sec := t.Unix() + if sec < 0 { + return 0, fmt.Errorf("height %d reports a pre-1970 block time (%d), refusing to attest it", height, sec) + } + return uint64(sec), nil +} + +// commitment reads a packet commitment straight out of cbdc-node's IBC store. +// An absent commitment is an error, never a zero value: signing a zero would +// attest that a packet exists when it does not. +func (c *cbdcClient) commitment(ctx context.Context, path []byte, height uint64) ([32]byte, error) { + var zero [32]byte + q := url.Values{} + q.Set("path", `"store/ibc/key"`) + q.Set("data", "0x"+hex.EncodeToString(path)) + q.Set("height", fmt.Sprintf("%d", height)) + + var out struct { + Result struct { + Response struct { + Value string `json:"value"` + Code int `json:"code"` + Log string `json:"log"` + } `json:"response"` + } `json:"result"` + } + if err := c.get(ctx, "/abci_query?"+q.Encode(), &out); err != nil { + return zero, err + } + if out.Result.Response.Code != 0 { + return zero, fmt.Errorf("abci query failed: %s", out.Result.Response.Log) + } + if out.Result.Response.Value == "" { + return zero, fmt.Errorf("no commitment at that path and height -- refusing to attest a packet this node cannot see") + } + raw, err := base64.StdEncoding.DecodeString(out.Result.Response.Value) + if err != nil { + return zero, fmt.Errorf("decode commitment: %w", err) + } + if len(raw) != 32 { + return zero, fmt.Errorf("commitment is %d bytes, want 32", len(raw)) + } + copy(zero[:], raw) + return zero, nil +} + +// provenAbsent returns nil only when cbdc-node has POSITIVELY shown that no +// value exists at path as of height. This backs the timeout attestation -- +// the signature that releases the counterparty's escrow -- so every ambiguous +// outcome must land on the error side. The trap is that "no value" is the +// natural result of half a dozen failures that prove nothing: the SDK's IAVL +// store answers a query for a PRUNED OR NONEXISTENT version with code 0 and +// an empty value (only the log mentions the missing version), which without +// countermeasures is byte-for-byte identical to genuine absence. +// +// The countermeasure is prove=1. With proving requested, the store must build +// an absence proof from the tree at exactly that version, and rootmulti turns +// "version not available" into a hard error (non-zero code) instead of an +// empty success. So the acceptance test is: code 0, AND the response echoes +// the height we asked about, AND proof ops are present -- the node did the +// work of proving absence, we are not inferring it from silence. We do not +// verify the IAVL proof itself: the node is our own trust anchor (the same +// one every membership attestation reads), and what prove=1 buys is the +// disambiguation, not extra trust. +func (c *cbdcClient) provenAbsent(ctx context.Context, path []byte, height uint64) error { + // height 0 means "latest" to the RPC, and prove=1 is rejected below height + // 2 anyway; a floating height must never anchor an absence claim. + if height < 2 { + return fmt.Errorf("refusing to attest absence at height %d: absence is only meaningful at a fixed height above 1", height) + } + q := url.Values{} + q.Set("path", `"store/ibc/key"`) + q.Set("data", "0x"+hex.EncodeToString(path)) + q.Set("height", fmt.Sprintf("%d", height)) + // "true", not "1": the RPC's HTTP arg parser decodes prove as a JSON bool + // and 500s on a number, which lands in the "node could not answer" branch + // below -- every absence request refused, the refund path dead on arrival. + q.Set("prove", "true") + + var out struct { + Result struct { + Response struct { + Value string `json:"value"` + Code int `json:"code"` + Log string `json:"log"` + Height string `json:"height"` + ProofOps *struct { + Ops []json.RawMessage `json:"ops"` + } `json:"proofOps"` + } `json:"response"` + } `json:"result"` + } + if err := c.get(ctx, "/abci_query?"+q.Encode(), &out); err != nil { + return fmt.Errorf("node could not answer, which proves nothing: %w", err) + } + resp := out.Result.Response + if resp.Code != 0 { + return fmt.Errorf("abci query failed (code %d): %s -- a failed query is not absence", resp.Code, resp.Log) + } + if resp.Height != fmt.Sprintf("%d", height) { + return fmt.Errorf("node answered for height %s, not the requested %d -- refusing to attest absence at a height the node did not evaluate", resp.Height, height) + } + if resp.ProofOps == nil || len(resp.ProofOps.Ops) == 0 { + return fmt.Errorf("node returned no proof ops for height %d -- an unproven empty answer is indistinguishable from a pruned or missing version, refusing", height) + } + if resp.Value != "" { + return fmt.Errorf("a value EXISTS at that path and height -- the packet was received; attesting its absence would release escrow that must not be released") + } + return nil +} + +// blockHash returns a block's hash (/block -> result.block_id.hash). +// +// Block 1's hash is the chain-INSTANCE identity the re-genesis check records: +// it changes on every re-genesis (genesis time and app hash feed it) even when +// the chain id is reused, which the /status network field cannot detect. +func (c *cbdcClient) blockHash(ctx context.Context, height uint64) (string, error) { + var out struct { + Result struct { + BlockID struct { + Hash string `json:"hash"` + } `json:"block_id"` + } `json:"result"` + } + if err := c.get(ctx, fmt.Sprintf("/block?height=%d", height), &out); err != nil { + return "", err + } + if out.Result.BlockID.Hash == "" { + return "", fmt.Errorf("height %d not found on this node", height) + } + return out.Result.BlockID.Hash, nil +} + +// network reports the chain id the node itself claims (/status -> +// node_info.network), so startup can refuse a node that is not the chain this +// process was configured to attest. +func (c *cbdcClient) network(ctx context.Context) (string, error) { + var out struct { + Result struct { + NodeInfo struct { + Network string `json:"network"` + } `json:"node_info"` + } `json:"result"` + } + if err := c.get(ctx, "/status", &out); err != nil { + return "", err + } + if out.Result.NodeInfo.Network == "" { + return "", fmt.Errorf("node reported no network in /status") + } + return out.Result.NodeInfo.Network, nil +} + +// latestHeight reports the head this node has actually seen. +func (c *cbdcClient) latestHeight(ctx context.Context) (uint64, error) { + var out struct { + Result struct { + SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + } `json:"sync_info"` + } `json:"result"` + } + if err := c.get(ctx, "/status", &out); err != nil { + return 0, err + } + var h uint64 + if _, err := fmt.Sscanf(out.Result.SyncInfo.LatestBlockHeight, "%d", &h); err != nil { + return 0, fmt.Errorf("parse height: %w", err) + } + return h, nil +} diff --git a/cmd/qbftattestor/cbdc_test.go b/cmd/qbftattestor/cbdc_test.go new file mode 100644 index 0000000..57703c0 --- /dev/null +++ b/cmd/qbftattestor/cbdc_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" +) + +// abciResponse is the shape /abci_query returns; tests vary it to walk +// provenAbsent through every way a node can fail to prove absence. +type abciResponse struct { + Code int `json:"code"` + Log string `json:"log"` + Value string `json:"value"` + Height string `json:"height"` + ProofOps any `json:"proofOps,omitempty"` +} + +func fakeNode(t *testing.T, resp abciResponse, sawQuery *map[string]string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/abci_query"): + if sawQuery != nil { + m := map[string]string{} + for k, v := range r.URL.Query() { + m[k] = v[0] + } + *sawQuery = m + } + _ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"response": resp}}) + case strings.HasPrefix(r.URL.Path, "/block"): + _ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{ + "block": map[string]any{"header": map[string]any{"time": "2026-07-31T12:00:00Z", "height": "7"}}, + }}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +var provenEmpty = abciResponse{ + Code: 0, Value: "", Height: "7", + ProofOps: map[string]any{"ops": []map[string]any{{"type": "ics23:iavl", "key": "", "data": ""}}}, +} + +// The one accepting case: the node succeeded, answered for exactly the height +// asked, produced proof ops, and showed no value. Everything else must refuse. +func TestProvenAbsent_AcceptsOnlyAProvenEmptyAnswer(t *testing.T) { + var q map[string]string + srv := fakeNode(t, provenEmpty, &q) + c := &cbdcClient{rpc: srv.URL} + + require.NoError(t, c.provenAbsent(context.Background(), []byte("path"), 7)) + // prove=true is what turns "version missing" into a hard error + // server-side; dropping it silently reopens the pruned-height ambiguity. + // It must be the string "true": the RPC decodes prove as a JSON bool and + // 500s on "1", which this fake (like any mock) cannot catch. + require.Equal(t, "true", q["prove"]) + require.Equal(t, "7", q["height"]) +} + +func TestProvenAbsent_RefusesWhenValueExists(t *testing.T) { + resp := provenEmpty + resp.Value = "AQ==" // the receipt sentinel: the packet WAS received + srv := fakeNode(t, resp, nil) + c := &cbdcClient{rpc: srv.URL} + + err := c.provenAbsent(context.Background(), []byte("path"), 7) + require.ErrorContains(t, err, "EXISTS") +} + +func TestProvenAbsent_RefusesFailedQuery(t *testing.T) { + srv := fakeNode(t, abciResponse{Code: 18, Log: "failed to load state at height 7", Height: "7"}, nil) + c := &cbdcClient{rpc: srv.URL} + + err := c.provenAbsent(context.Background(), []byte("path"), 7) + require.ErrorContains(t, err, "not absence") +} + +func TestProvenAbsent_RefusesAnswerForDifferentHeight(t *testing.T) { + resp := provenEmpty + resp.Height = "6" + srv := fakeNode(t, resp, nil) + c := &cbdcClient{rpc: srv.URL} + + err := c.provenAbsent(context.Background(), []byte("path"), 7) + require.ErrorContains(t, err, "did not evaluate") +} + +// The trap this whole function exists for: the SDK's IAVL store answers a +// query for a pruned or nonexistent version with code 0 and an empty value -- +// indistinguishable from genuine absence except for the missing proof ops. +func TestProvenAbsent_RefusesEmptyAnswerWithoutProof(t *testing.T) { + resp := provenEmpty + resp.ProofOps = nil + srv := fakeNode(t, resp, nil) + c := &cbdcClient{rpc: srv.URL} + + err := c.provenAbsent(context.Background(), []byte("path"), 7) + require.ErrorContains(t, err, "pruned") +} + +func TestProvenAbsent_RefusesFloatingHeightWithoutAsking(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("height < 2 must be refused before the node is even consulted") + })) + defer srv.Close() + c := &cbdcClient{rpc: srv.URL} + + require.Error(t, c.provenAbsent(context.Background(), []byte("path"), 0)) + require.Error(t, c.provenAbsent(context.Background(), []byte("path"), 1)) +} + +func TestProvenAbsent_RefusesUnreachableNode(t *testing.T) { + srv := fakeNode(t, provenEmpty, nil) + srv.Close() // node down: "could not answer" proves nothing + c := &cbdcClient{rpc: srv.URL} + + require.Error(t, c.provenAbsent(context.Background(), []byte("path"), 7)) +} + +// End to end through the handler: the signed payload must be EXACTLY the +// non-membership shape the contract checks -- {keccak256(receiptPath), +// bytes32(0)} at the requested height. crypto.Sign is deterministic (RFC +// 6979), so the whole proof can be compared byte for byte. +func TestAttestAbsence_SignsTheNonMembershipShape(t *testing.T) { + srv := fakeNode(t, provenEmpty, nil) + key, err := crypto.GenerateKey() + require.NoError(t, err) + s := &server{key: attestor.NewLocalSigner(key), chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + + req := httptest.NewRequest(http.MethodPost, "/attest/absence", strings.NewReader(`{"height":7,"sequences":[3]}`)) + rec := httptest.NewRecorder() + s.attestAbsence(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var out struct { + Proof string `json:"proof"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + + want, err := attestor.PacketProof(attestor.NewLocalSigner(key), 7, []attestor.PacketCompact{{ + Path: attestor.ReceiptPathHash("qbftclient-0", 3), + // Commitment stays zero: that IS the absence claim. + }}) + require.NoError(t, err) + require.Equal(t, "0x"+hex.EncodeToString(want), out.Proof) +} + +func TestAttestAbsence_RefusesWhenReceiptExists(t *testing.T) { + resp := provenEmpty + resp.Value = "AQ==" + srv := fakeNode(t, resp, nil) + key, err := crypto.GenerateKey() + require.NoError(t, err) + s := &server{key: attestor.NewLocalSigner(key), chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + + req := httptest.NewRequest(http.MethodPost, "/attest/absence", strings.NewReader(`{"height":7,"sequences":[3]}`)) + rec := httptest.NewRecorder() + s.attestAbsence(rec, req) + require.Equal(t, http.StatusBadGateway, rec.Code) + require.Contains(t, rec.Body.String(), "REFUSING") +} + +// attestAck reads the ack commitment from the node's own store; an absent ack +// must be an error, never a zero -- a zero here would BE an absence claim. +func TestAttestAck_RefusesAbsentAck(t *testing.T) { + srv := fakeNode(t, abciResponse{Code: 0, Value: "", Height: "7"}, nil) + key, err := crypto.GenerateKey() + require.NoError(t, err) + s := &server{key: attestor.NewLocalSigner(key), chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + + req := httptest.NewRequest(http.MethodPost, "/attest/ack", strings.NewReader(`{"height":7,"sequences":[3]}`)) + rec := httptest.NewRecorder() + s.attestAck(rec, req) + require.Equal(t, http.StatusBadGateway, rec.Code) + require.Contains(t, rec.Body.String(), "refusing") +} + +func TestAttestAck_SignsTheStoredCommitment(t *testing.T) { + stored := [32]byte{0xac, 0x01} + srv := fakeNode(t, abciResponse{ + Code: 0, Height: "7", + Value: jsonB64(stored[:]), + }, nil) + key, err := crypto.GenerateKey() + require.NoError(t, err) + s := &server{key: attestor.NewLocalSigner(key), chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + + req := httptest.NewRequest(http.MethodPost, "/attest/ack", strings.NewReader(`{"height":7,"sequences":[3]}`)) + rec := httptest.NewRecorder() + s.attestAck(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var out struct { + Proof string `json:"proof"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + want, err := attestor.PacketProof(attestor.NewLocalSigner(key), 7, []attestor.PacketCompact{{ + Path: attestor.AckPathHash("qbftclient-0", 3), + Commitment: stored, + }}) + require.NoError(t, err) + require.Equal(t, "0x"+hex.EncodeToString(want), out.Proof) +} + +// jsonB64 encodes bytes the way CometBFT's JSON does (std base64), via the +// same json machinery the client decodes with. +func jsonB64(b []byte) string { + out, _ := json.Marshal(b) + return strings.Trim(string(out), `"`) +} diff --git a/cmd/qbftattestor/grpc.go b/cmd/qbftattestor/grpc.go new file mode 100644 index 0000000..d5e4b43 --- /dev/null +++ b/cmd/qbftattestor/grpc.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "fmt" + "log" + "net" + + "google.golang.org/grpc" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" + pb "github.com/peersyst/cbdc-node/x/qbftclient/attestor/aggregatorpb" + apb "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" +) + +// aggregatorServer speaks the AggregatorService gRPC interface that upstream's +// `cosmos-to-eth` calls in Attested mode. Implementing it is what lets +// cosmos/ibc-relayer drive the outbound leg: the relayer asks proof-api for a +// transaction, proof-api asks this service for signatures. +// +// It shares the HTTP server's verification path deliberately. The security +// property is the same either way: the caller supplies a height and a set of +// packets to look up, never a timestamp and never a commitment. Everything +// signed is read from cbdc-node by this process. +type aggregatorServer struct { + s *server +} + +// GetAttestations returns both attestations upstream needs in one call: the +// state attestation that advances the light client, and the packet attestation +// that proves membership at that height. +func (a *aggregatorServer) GetAttestations(ctx context.Context, req *pb.GetAttestationsRequest) (*pb.GetAttestationsResponse, error) { + if req.GetHeight() == 0 { + return nil, fmt.Errorf("height is required") + } + height := req.GetHeight() + + ts, err := a.s.chain.blockTimeSeconds(ctx, height) + if err != nil { + return nil, fmt.Errorf("cannot verify height %d: %w", height, err) + } + + // Same freeze guard as the HTTP path. Two different timestamps for one + // height is terminal, so refuse rather than let a caller induce it. The + // record is durable before anything is signed (see guard in state.go). + if err := a.s.guard(height, ts); err != nil { + return nil, err + } + + stateData, err := attestor.EncodeState(height, ts) + if err != nil { + return nil, err + } + stateSig, err := attestor.Sign(a.s.key, attestor.Digest(stateData, attestor.TagState)) + if err != nil { + return nil, err + } + + // req.Packets carries the ICS-24 paths to attest (any kind -- commitment + // or ack -- since the value is read from our own node either way). We hash + // them ourselves and read each commitment from our own store -- a caller + // cannot smuggle in a commitment value. + // + // ABSENCE (timeout) attestation is deliberately NOT reachable here: the + // upstream GetAttestationsRequest has no field that could distinguish + // "attest this value" from "attest there is no value", and inferring the + // latter from an empty store read is exactly the bug class the absence + // path must avoid. HTTP's /attest/absence, where the intent is explicit, + // is the only absence surface. + compacts := make([]attestor.PacketCompact, 0, len(req.GetPackets())) + for _, path := range req.GetPackets() { + commitment, err := a.s.chain.commitment(ctx, path, height) + if err != nil { + return nil, fmt.Errorf("cannot verify packet at height %d: %w", height, err) + } + compacts = append(compacts, attestor.PacketCompact{ + Path: keccakPath(path), + Commitment: commitment, + }) + } + + resp := &pb.GetAttestationsResponse{ + StateAttestation: &pb.AggregatedAttestation{ + Height: height, + Timestamp: &ts, + AttestedData: stateData, + Signatures: [][]byte{stateSig}, + }, + } + + if len(compacts) > 0 { + packetData, err := attestor.EncodePackets(height, compacts) + if err != nil { + return nil, err + } + packetSig, err := attestor.Sign(a.s.key, attestor.Digest(packetData, attestor.TagPacket)) + if err != nil { + return nil, err + } + resp.PacketAttestation = &pb.AggregatedAttestation{ + Height: height, + AttestedData: packetData, + Signatures: [][]byte{packetSig}, + } + } + + log.Printf("grpc: attested height=%d ts=%d packets=%d", height, ts, len(compacts)) + return resp, nil +} + +func serveGRPC(addr string, s *server) error { + lis, err := net.Listen("tcp", addr) + if err != nil { + return err + } + srv := grpc.NewServer() + pb.RegisterAggregatorServiceServer(srv, &aggregatorServer{s: s}) + // Both interfaces on one port. AttestationService is what cmd/qbftproofapi + // speaks and what cosmos/ibc-attestor serves natively, so serving it here + // makes the two sidecars swappable by address alone -- in both directions. + // AggregatorService stays for upstream `cosmos-to-eth`, which speaks only + // that, and which cannot express receipt absence over it. + apb.RegisterAttestationServiceServer(srv, &attestationServer{s: s}) + log.Printf("attestor gRPC on %s (AttestationService + AggregatorService)", addr) + return srv.Serve(lis) +} diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go new file mode 100644 index 0000000..eb4d721 --- /dev/null +++ b/cmd/qbftattestor/main.go @@ -0,0 +1,449 @@ +// Command qbftattestor is the attestor sidecar for the outbound corridor leg. +// +// # WHAT IT IS FOR +// +// The spoke's AttestationLightClient does not verify cbdc-node's consensus. It +// verifies m-of-n signatures asserting that a height had a timestamp, or that a +// packet commitment existed. Something has to produce those signatures, and that +// something is this process. +// +// # THE ONE RULE THAT MATTERS +// +// An attestor NEVER signs what it is told. It signs what it has independently +// verified against its own view of cbdc-node. The relayer asks "please attest +// height H"; this process queries cbdc-node itself, and signs only its own +// answer. If it signed the caller's claims, the relayer could mint vouchers out +// of nothing and the entire trust model would be theater -- the signature would +// attest to the relayer's honesty rather than the chain's state. +// +// That is why this is a separate process from the relayer, holds the only key, +// and exposes no endpoint that accepts a timestamp or a commitment as input. +// +// # FREEZE SAFETY +// +// Signing two different timestamps for one height freezes the client +// permanently, with no unfreeze. Binding the signature itself to a chain +// identity is not possible -- the payload is {height, timestamp} and the +// deployed contract abi.decodes it into its own struct (see the attestor +// package doc) -- so the guard is process-side, in two layers: +// +// 1. Every (height, timestamp) this process attests is appended to a log +// under -state-dir and fsync'd BEFORE the signature is produced, and +// reloaded at startup. Durable before signing, never after: a crash +// between signing and recording would leave a signature the guard has +// forgotten. In memory only, the guard protected nothing across a +// restart, which is exactly when it was needed. +// +// 2. The hash of block 1 is recorded at first run and compared on every +// start. A re-genesis (local-node.sh does `rm -rf $HOMEDIR`, so it is the +// routine dev workflow) restarts heights from 1 with new timestamps while +// keeping the chain id, so the /status chain-id check below cannot see +// it -- but block 1's hash changes. On mismatch this process refuses to +// start, because a restarted sidecar with a wiped or empty log would +// happily re-sign height N against a light client that still holds the +// old timestamp. -reset-state is the explicit escape hatch for when the +// light client has ALSO been redeployed. +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" +) + +type server struct { + // key is the Signer, not a raw private key: custody is the backend's + // concern (see x/qbftclient/attestor/signer.go), not this process's. + key attestor.Signer + chain *cbdcClient + client string // the cbdc-node client id packets are sent from + + mu sync.Mutex + seen map[uint64]uint64 // height -> timestamp already attested + // seenLog is the durable backing for `seen`: append-only, fsync'd before + // any signature is produced (see state.go). + seenLog *os.File + // seenCap bounds `seen` in memory (seenLimit in production; tests shrink it). + seenCap int + // lowWater is the highest height evicted from `seen`. At or below it, + // absence from the map proves nothing, so guard refuses to sign. + lowWater uint64 +} + +// seenLimit bounds the in-memory `seen` map, which would otherwise grow by one +// entry per attested height forever. +// +// Eviction no longer un-guards a height: guard REFUSES anything at or below +// the eviction low-water mark instead of signing it unchecked, because absence +// from the map proves nothing there and the append-only log has no index to +// consult. A refusal cannot freeze the client; an unchecked signature can. +// The durable log itself is never trimmed -- at ~30 bytes per attested height +// it costs a few MB per hundred thousand heights, cheap next to what it +// protects, and trimming it would silently narrow the restart guarantee. +const seenLimit = 100_000 + +func main() { + var ( + rpc = flag.String("cbdc-rpc", "http://127.0.0.1:26657", "cbdc-node Tendermint RPC") + listen = flag.String("listen", "127.0.0.1:8ono", "address to serve on") + clientID = flag.String("client-id", "qbftclient-0", "cbdc-node client id packets are sent from") + keyHex = flag.String("key", "", "attestor secp256k1 private key hex (or ATTESTOR_KEY env)") + grpcAddr = flag.String("grpc", "127.0.0.1:8091", "AggregatorService gRPC address, as upstream cosmos-to-eth expects") + // The signed payload carries no domain separation (see the attestor + // package doc), so nothing in the signature binds it to one chain or + // one light client -- the only binding is this process configuration. + // All three are therefore required, not defaulted: a sidecar that does + // not know its one legitimate target must not start. + cbdcChainID = flag.String("cbdc-chain-id", "", "cosmos chain id this process attests (required, checked against the node)") + lightCli = flag.String("light-client", "", "AttestationLightClient address this key signs for (required)") + besuChainID = flag.Uint64("besu-chain-id", 0, "EVM chain id the light client lives on (required)") + stateDir = flag.String("state-dir", "", "directory for the durable freeze-guard state (required)") + reset = flag.Bool("reset-state", false, "DANGEROUS: discard the recorded chain identity and attested-height log; only valid when the light client has been redeployed") + ) + flag.Parse() + + if *listen == "127.0.0.1:8ono" { + *listen = "127.0.0.1:8090" + } + k := *keyHex + if k == "" { + k = os.Getenv("ATTESTOR_KEY") + } + if k == "" { + log.Fatal("attestor key required: -key or ATTESTOR_KEY") + } + if *cbdcChainID == "" || *lightCli == "" || *besuChainID == 0 { + log.Fatal("required: -cbdc-chain-id -light-client -besu-chain-id (signatures carry no domain separation; this binding is all there is)") + } + if *stateDir == "" { + log.Fatal("required: -state-dir (the freeze guard must survive restarts; see the package doc)") + } + key, err := crypto.HexToECDSA(trim0x(k)) + if err != nil { + log.Fatalf("bad key: %v", err) + } + + // Local custody is still the default; -kms-key-id selects a remote backend + // once one is implemented. The rest of the process cannot tell them apart. + signer := attestor.NewLocalSigner(key) + + s := &server{ + key: signer, + chain: &cbdcClient{rpc: *rpc}, + client: *clientID, + seen: map[uint64]uint64{}, + seenCap: seenLimit, + } + + // A signature from this key verifies against any client trusting it, so a + // sidecar pointed at the wrong node signs freely and nothing downstream + // notices. Check the one thing that CAN be checked before signing anything: + // that the node really is the configured chain. This is what catches a + // re-genesis under a new id or a -cbdc-rpc pointed at the wrong network. + network, err := s.chain.network(context.Background()) + if err != nil { + log.Fatalf("cannot verify chain id against %s: %v", *rpc, err) + } + if network != *cbdcChainID { + log.Fatalf("refusing to start: configured -cbdc-chain-id %q but node at %s reports %q", *cbdcChainID, *rpc, network) + } + + if err := os.MkdirAll(*stateDir, 0o700); err != nil { + log.Fatalf("state dir: %v", err) + } + if *reset { + log.Printf("!!! -reset-state: DISCARDING the recorded chain identity and attested-height log in %s", *stateDir) + log.Printf("!!! this is safe ONLY if the AttestationLightClient has been redeployed; against the existing client, re-signing repeated heights WILL freeze it permanently") + if err := resetState(*stateDir); err != nil { + log.Fatalf("reset state: %v", err) + } + } + // The chain-id check above cannot see a re-genesis that reuses the id; + // block 1's hash can, so pin the state dir to the chain INSTANCE. + b1, err := s.chain.blockHash(context.Background(), 1) + if err != nil { + log.Fatalf("cannot read block 1 hash from %s (needed for re-genesis detection): %v", *rpc, err) + } + if err := checkGenesis(*stateDir, *cbdcChainID, b1); err != nil { + log.Fatalf("refusing to start: %v", err) + } + if err := s.openState(*stateDir); err != nil { + log.Fatalf("state: %v", err) + } + + log.Printf("attestor %s", signer.Address()) + log.Printf("verifying against %s, signing for client %s", *rpc, *clientID) + log.Printf("bound to chain %s (confirmed by node), attesting for light client %s on EVM chain %d", *cbdcChainID, *lightCli, *besuChainID) + log.Printf("listening on %s", *listen) + + if *grpcAddr != "" { + go func() { + if err := serveGRPC(*grpcAddr, s); err != nil { + log.Fatalf("grpc: %v", err) + } + }() + } + + http.HandleFunc("/attest/state", s.attestState) + http.HandleFunc("/attest/packet", s.attestPacket) + http.HandleFunc("/attest/ack", s.attestAck) + http.HandleFunc("/attest/absence", s.attestAbsence) + http.HandleFunc("/address", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]string{"address": signer.Address().Hex()}) + }) + // An explicit Server rather than ListenAndServe: the default has no timeouts + // at all, so a client that opens a connection and never finishes its headers + // holds a goroutine for as long as it likes. This process holds the attestor + // key, so starving it is worth a stranger's while. + srv := &http.Server{ + Addr: *listen, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Fatal(srv.ListenAndServe()) +} + +// attestState signs (height, timestamp) where the timestamp is READ FROM THE +// CHAIN, never taken from the request. The caller supplies only a height. +func (s *server) attestState(w http.ResponseWriter, r *http.Request) { + var req struct { + Height uint64 `json:"height"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Height == 0 { + http.Error(w, "height required", http.StatusBadRequest) + return + } + + ts, err := s.chain.blockTimeSeconds(r.Context(), req.Height) + if err != nil { + http.Error(w, fmt.Sprintf("cannot verify height %d: %v", req.Height, err), http.StatusBadGateway) + return + } + + // Freeze guard. Two different timestamps for one height is the only way to + // brick the client, and it is unrecoverable, so refuse rather than risk it. + // The record is durable before anything is signed (see guard in state.go). + if err := s.guard(req.Height, ts); err != nil { + status := http.StatusConflict + if errors.Is(err, errNotDurable) { + status = http.StatusInternalServerError + } + http.Error(w, err.Error(), status) + return + } + + proof, err := attestor.StateProof(s.key, req.Height, ts) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("attested state height=%d ts=%d", req.Height, ts) + writeJSON(w, map[string]any{"height": req.Height, "timestamp": ts, "proof": "0x" + hex.EncodeToString(proof)}) +} + +// attestPacket signs a membership claim only after reading the commitment out of +// cbdc-node's own store at that height. The caller supplies the sequence; the +// commitment is ours. +// +// The near-duplicate of attestAck is deliberate. Folding them into one helper +// parameterised by path builder would save ~30 lines and introduce the one +// mistake this file cannot afford: CommitmentPath/PathHash and AckPath/ +// AckPathHash must never be crossed, and a signature over the wrong path hash +// verifies against nothing. Two explicit handlers keep that pairing local and +// readable. dupl is right about the shape and wrong about the trade. +// +//nolint:dupl // parallel-by-design; see note above +func (s *server) attestPacket(w http.ResponseWriter, r *http.Request) { + var req struct { + Height uint64 `json:"height"` + Sequence []uint64 `json:"sequences"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Height == 0 || len(req.Sequence) == 0 { + http.Error(w, "height and sequences required", http.StatusBadRequest) + return + } + + packets := make([]attestor.PacketCompact, 0, len(req.Sequence)) + for _, seq := range req.Sequence { + path := attestor.CommitmentPath(s.client, seq) + commitment, err := s.chain.commitment(r.Context(), path, req.Height) + if err != nil { + http.Error(w, fmt.Sprintf("cannot verify packet %d at height %d: %v", seq, req.Height, err), http.StatusBadGateway) + return + } + packets = append(packets, attestor.PacketCompact{ + Path: attestor.PathHash(s.client, seq), + Commitment: commitment, + }) + } + + proof, err := attestor.PacketProof(s.key, req.Height, packets) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("attested %d packet(s) at height=%d", len(packets), req.Height) + writeJSON(w, map[string]any{"height": req.Height, "proof": "0x" + hex.EncodeToString(proof)}) +} + +// attestAck signs a membership claim for the ACKNOWLEDGEMENT path (kind 3): +// that cbdc-node wrote an ack for a packet it received. The spoke's router +// verifies it in ackPacket, which is what finally clears the send commitment +// (and with it the escrow hold) for a delivered packet. Same discipline as +// attestPacket: the caller supplies sequences, the ack commitment is read from +// our own node, and an ABSENT ack is an error, never a zero. +// +// The near-duplicate of attestAck is deliberate. Folding them into one helper +// parameterised by path builder would save ~30 lines and introduce the one +// mistake this file cannot afford: CommitmentPath/PathHash and AckPath/ +// AckPathHash must never be crossed, and a signature over the wrong path hash +// verifies against nothing. Two explicit handlers keep that pairing local and +// readable. dupl is right about the shape and wrong about the trade. +// +//nolint:dupl // parallel-by-design; see note above +func (s *server) attestAck(w http.ResponseWriter, r *http.Request) { + var req struct { + Height uint64 `json:"height"` + Sequence []uint64 `json:"sequences"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Height == 0 || len(req.Sequence) == 0 { + http.Error(w, "height and sequences required", http.StatusBadRequest) + return + } + + packets := make([]attestor.PacketCompact, 0, len(req.Sequence)) + for _, seq := range req.Sequence { + // The ack is keyed by the packet's DESTINATION client, which for a + // packet received on cbdc-node is the same client id this process is + // configured with -- both corridor directions share qbftclient-0 on + // the cbdc-node side. + path := attestor.AckPath(s.client, seq) + commitment, err := s.chain.commitment(r.Context(), path, req.Height) + if err != nil { + http.Error(w, fmt.Sprintf("cannot verify ack %d at height %d: %v", seq, req.Height, err), http.StatusBadGateway) + return + } + packets = append(packets, attestor.PacketCompact{ + Path: attestor.AckPathHash(s.client, seq), + Commitment: commitment, + }) + } + + proof, err := attestor.PacketProof(s.key, req.Height, packets) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("attested %d ack(s) at height=%d", len(packets), req.Height) + writeJSON(w, map[string]any{"height": req.Height, "proof": "0x" + hex.EncodeToString(proof)}) +} + +// attestAbsence signs a NON-membership claim: that no receipt exists for a +// sequence at a height. This is the most dangerous signature this process can +// produce. A membership attestation gone wrong mints at most a voucher the +// chain state backs; an absence attestation gone wrong -- signing "never +// received" about a packet that WAS received -- releases the counterparty's +// escrow while the recipient keeps the funds. So everything here is built to +// refuse: the sidecar queries the receipt path itself (kind 2, keyed by this +// side's client id -- NOT the kind 1 commitment path), and provenAbsent +// accepts nothing short of the node positively proving absence at exactly the +// requested height. "The node did not show a value" is never enough. +// +// Is absence at one height even safe to attest, when the packet could be +// received LATER? Yes, but only together with the timeout check the contract +// performs, and it is worth spelling out why the pieces interlock: +// +// - verifyNonMembership returns the trusted timestamp at the proof height, +// and the router requires it to be >= the packet's timeoutTimestamp +// before refunding (ICS26Router.timeoutPacket). +// - cbdc-node refuses to receive a packet whose timeout has passed +// (ibc-go recvPacket: currentTimestamp >= timeoutTimestamp is rejected), +// and block time is monotonic. +// +// So if the refund goes through, block time at the attested height had already +// passed the timeout, which means every later block is also past it and the +// receipt can never legally appear. Absence then really is permanent. +// +// What this process CANNOT enforce is the ts >= timeout comparison itself: +// the packet's timeout lives in the packet body on the spoke, outside this +// sidecar's one trust anchor, and an attacker-supplied copy of it would be +// worthless. The comparison therefore stays on-chain, evaluated against the +// same timestamp this process attests via /attest/state -- one trust base, +// checked where both inputs are authentic. Signing absence at a too-early +// height is thereby harmless: the router rejects the timeout, and nothing +// in the signature can be repurposed as a membership claim (a zero +// commitment never equals a real one in verifyMembership). +func (s *server) attestAbsence(w http.ResponseWriter, r *http.Request) { + var req struct { + Height uint64 `json:"height"` + Sequence []uint64 `json:"sequences"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Height == 0 || len(req.Sequence) == 0 { + http.Error(w, "height and sequences required", http.StatusBadRequest) + return + } + + // The block must exist on our node before its state is worth asking about, + // and its time is what the router will compare against the packet timeout, + // so return it: a caller can see BEFORE submitting whether the timeout has + // actually passed at this height instead of burning gas to find out. + ts, err := s.chain.blockTimeSeconds(r.Context(), req.Height) + if err != nil { + http.Error(w, fmt.Sprintf("cannot verify height %d: %v", req.Height, err), http.StatusBadGateway) + return + } + + packets := make([]attestor.PacketCompact, 0, len(req.Sequence)) + for _, seq := range req.Sequence { + path := attestor.ReceiptPath(s.client, seq) + if err := s.chain.provenAbsent(r.Context(), path, req.Height); err != nil { + http.Error(w, fmt.Sprintf("REFUSING to attest absence of receipt %d at height %d: %v", seq, req.Height, err), http.StatusBadGateway) + return + } + // Commitment stays the zero value: {receiptPathHash, bytes32(0)} is + // exactly what verifyNonMembership demands for a timeout. + packets = append(packets, attestor.PacketCompact{ + Path: attestor.ReceiptPathHash(s.client, seq), + }) + } + + proof, err := attestor.PacketProof(s.key, req.Height, packets) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("attested ABSENCE of %d receipt(s) at height=%d ts=%d", len(packets), req.Height, ts) + writeJSON(w, map[string]any{"height": req.Height, "timestamp": ts, "proof": "0x" + hex.EncodeToString(proof)}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func trim0x(s string) string { + if len(s) > 2 && (s[:2] == "0x" || s[:2] == "0X") { + return s[2:] + } + return s +} + +// keccakPath hashes a full ICS-24 path for PacketCompact.Path. +func keccakPath(path []byte) [32]byte { + return common.BytesToHash(crypto.Keccak256(path)) +} diff --git a/cmd/qbftattestor/state.go b/cmd/qbftattestor/state.go new file mode 100644 index 0000000..2d7942d --- /dev/null +++ b/cmd/qbftattestor/state.go @@ -0,0 +1,237 @@ +package main + +// Durable state for the freeze guard. Everything here exists because a guard +// held only in memory is worthless in the one scenario that matters -- a +// restart: the light client freezes permanently on the SECOND signature, so +// the record of the first must outlive the process. +// +// Layout under -state-dir: +// +// seen.jsonl append-only log, one {"height","timestamp"} JSON line per +// attested height, fsync'd BEFORE any signature is produced +// genesis.json the chain instance this state belongs to, recorded at first +// run and compared on every start (re-genesis detection) + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "sort" +) + +const ( + seenFile = "seen.jsonl" + genesisFile = "genesis.json" +) + +// errNotDurable marks guard refusals caused by the record not reaching disk, +// as opposed to a timestamp conflict. Callers report it as a server-side +// failure, but the response is the same: no signature. +var errNotDurable = errors.New("freeze guard not durable") + +type seenRecord struct { + Height uint64 `json:"height"` + Timestamp uint64 `json:"timestamp"` +} + +// genesisMarker pins the state dir to one chain INSTANCE, not just one chain +// id. Block 1's hash changes on every re-genesis (genesis time and app hash +// feed it) even when the id is reused, which is exactly the case the /status +// chain-id check cannot catch. +type genesisMarker struct { + ChainID string `json:"cbdc_chain_id"` + Block1Hash string `json:"block1_hash"` +} + +// guard records (height, ts) durably and returns nil only when signing that +// pair cannot contradict anything this process has ever signed. It must +// succeed BEFORE signing, never after: a crash between signing and recording +// would reopen the exact hole this exists to close, while a durable record +// whose signature was never produced is harmless. +func (s *server) guard(height, ts uint64) error { + s.mu.Lock() + defer s.mu.Unlock() + if prev, ok := s.seen[height]; ok { + if prev != ts { + return fmt.Errorf( + "REFUSING: already attested height %d as %d, now reading %d -- signing both would freeze the client permanently", + height, prev, ts) + } + return nil // same pair already recorded; re-signing it is idempotent + } + // Absence from the map proves nothing at or below the low-water mark: the + // entry may have been evicted, and the append-only log has no index to + // consult. Refusing is the only answer that cannot freeze the client, and + // heights this old are ones the corridor has long moved past. + if height <= s.lowWater { + return fmt.Errorf( + "REFUSING: height %d is at or below the eviction low-water mark %d, so a previous attestation for it can no longer be checked", + height, s.lowWater) + } + if err := s.appendSeenLocked(height, ts); err != nil { + return fmt.Errorf("%w: %v", errNotDurable, err) + } + s.seen[height] = ts + s.evictLocked() + return nil +} + +// appendSeenLocked writes one record and forces it to disk. The Sync is the +// point: a signature must never exist whose record a crash could forget. +// Caller must hold s.mu. +func (s *server) appendSeenLocked(height, ts uint64) error { + b, err := json.Marshal(seenRecord{Height: height, Timestamp: ts}) + if err != nil { + return err + } + if _, err := s.seenLog.Write(append(b, '\n')); err != nil { + return err + } + return s.seenLog.Sync() +} + +// openState loads the durable guard from dir and leaves s.seenLog open for +// appending. +// +// A trailing record with no newline is a crash mid-append: its signature was +// never produced (guard persists before signing), so it is truncated away -- +// truncated, not just skipped, or the next append would fuse with it into a +// corrupt record. Anything else that fails to parse means the log was edited +// or shared between processes, and signing on top of an untrusted log is the +// freeze risk itself, so it is fatal. +func (s *server) openState(dir string) error { + path := filepath.Join(dir, seenFile) + raw, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + rest, goodLen := raw, 0 + for len(rest) > 0 { + nl := bytes.IndexByte(rest, '\n') + if nl < 0 { + log.Printf("state: truncating torn trailing record %q from %s (crash mid-append; it was never signed)", rest, path) + if err := os.Truncate(path, int64(goodLen)); err != nil { + return fmt.Errorf("truncate torn record: %w", err) + } + break + } + line := rest[:nl] + rest = rest[nl+1:] + goodLen += nl + 1 + var rec seenRecord + if err := json.Unmarshal(line, &rec); err != nil { + return fmt.Errorf("corrupt record in %s: %q: %v", path, line, err) + } + if prev, ok := s.seen[rec.Height]; ok && prev != rec.Timestamp { + return fmt.Errorf( + "%s records two timestamps for height %d (%d then %d): the freeze guard has already been violated once; do NOT sign against the existing light client", + path, rec.Height, prev, rec.Timestamp) + } + s.seen[rec.Height] = rec.Timestamp + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + s.seenLog = f + if len(s.seen) > 0 { + s.mu.Lock() + s.evictLocked() + s.mu.Unlock() + log.Printf("state: loaded %d attested height(s) from %s", len(s.seen), path) + } + return nil +} + +// evictLocked bounds `seen` once it exceeds s.seenCap, raising lowWater past +// the dropped heights. Caller must hold s.mu. +// +// Eviction no longer un-guards a height: guard REFUSES anything at or below +// lowWater instead of signing it unchecked. The durable log keeps every record +// regardless -- it is only the in-memory index that is dropped. An extra 10% +// is dropped each time so the sort amortizes across thousands of attestations +// instead of running on every one once the cap is reached. +func (s *server) evictLocked() { + if len(s.seen) <= s.seenCap { + return + } + heights := make([]uint64, 0, len(s.seen)) + for h := range s.seen { + heights = append(heights, h) + } + sort.Slice(heights, func(i, j int) bool { return heights[i] < heights[j] }) + drop := len(s.seen) - s.seenCap + s.seenCap/10 + for _, h := range heights[:drop] { + delete(s.seen, h) + } + s.lowWater = heights[drop-1] + log.Printf("seen map trimmed to %d entries; heights at or below %d are now REFUSED rather than signed unguarded", len(s.seen), s.lowWater) +} + +// checkGenesis refuses to run against a different chain INSTANCE than the one +// this state dir was created for. The /status chain-id check in main cannot +// see a re-genesis that reuses the id -- local-node.sh's `rm -rf $HOMEDIR` +// does exactly that, and it restarts heights from 1 with new timestamps while +// the deployed light client still holds the old ones. +func checkGenesis(dir, chainID, block1Hash string) error { + path := filepath.Join(dir, genesisFile) + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + // First run: adopt this chain instance. Durable before anything is + // signed, for the same reason the seen log is. + b, err := json.Marshal(genesisMarker{ChainID: chainID, Block1Hash: block1Hash}) + if err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := f.Write(append(b, '\n')); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() + } + if err != nil { + return err + } + var m genesisMarker + if err := json.Unmarshal(bytes.TrimSpace(raw), &m); err != nil { + return fmt.Errorf("corrupt %s: %v -- if the light client has been redeployed, -reset-state discards it", path, err) + } + if m.ChainID != chainID { + return fmt.Errorf( + "state dir %s belongs to chain %q, not %q -- one state dir per corridor; the signed payload carries no domain separation, so sharing one defeats the guard", + dir, m.ChainID, chainID) + } + if m.Block1Hash != block1Hash { + return fmt.Errorf( + "chain %q has been RE-GENESISED: block 1 hash was %s when this attestor first ran, the node now reports %s. Heights are repeating with new timestamps; signing them with the same key would freeze the existing AttestationLightClient permanently and strand the escrow. If the light client has ALSO been redeployed, restart with -reset-state", + chainID, m.Block1Hash, block1Hash) + } + return nil +} + +// resetState discards the recorded chain identity and attested-height log. +// It is the only supported way out after a re-genesis, and it is safe ONLY +// when the light client has been redeployed too -- against the existing client +// it re-arms the exact freeze this state exists to prevent, hence the shouting +// where main invokes it. +func resetState(dir string) error { + for _, name := range []string{seenFile, genesisFile} { + if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} diff --git a/cmd/qbftattestor/state_test.go b/cmd/qbftattestor/state_test.go new file mode 100644 index 0000000..5699966 --- /dev/null +++ b/cmd/qbftattestor/state_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func newTestServer(t *testing.T, dir string, capacity int) *server { + t.Helper() + s := &server{seen: map[uint64]uint64{}, seenCap: capacity} + require.NoError(t, s.openState(dir)) + t.Cleanup(func() { s.seenLog.Close() }) + return s +} + +// The defect being fixed: the guard used to live only in memory, so a restart +// forgot every attested height. A fresh server over the same state dir must +// still refuse the conflicting timestamp. +func TestGuard_SurvivesRestart(t *testing.T) { + dir := t.TempDir() + + s := newTestServer(t, dir, seenLimit) + require.NoError(t, s.guard(7, 100)) + require.NoError(t, s.seenLog.Close()) + + s2 := newTestServer(t, dir, seenLimit) + err := s2.guard(7, 200) + require.ErrorContains(t, err, "freeze") + require.NoError(t, s2.guard(7, 100), "re-attesting the SAME pair is idempotent") +} + +// If the record cannot reach disk, no signature may be produced: a signed +// attestation the log would forget is exactly the restart hole reopened. +func TestGuard_RefusesWhenNotDurable(t *testing.T) { + dir := t.TempDir() + s := newTestServer(t, dir, seenLimit) + require.NoError(t, s.seenLog.Close()) // make the append fail + + err := s.guard(1, 100) + require.ErrorIs(t, err, errNotDurable) + require.NotContains(t, s.seen, uint64(1), + "a record that did not reach disk must not be trusted in memory either") +} + +// Eviction must refuse, not forget: an evicted height is refused with EITHER +// timestamp, because signing it unchecked is how eviction used to silently +// lose the freeze guarantee. +func TestGuard_EvictionRefusesInsteadOfForgetting(t *testing.T) { + dir := t.TempDir() + s := newTestServer(t, dir, 4) + for h := uint64(1); h <= 5; h++ { + require.NoError(t, s.guard(h, h*10)) + } + require.Equal(t, uint64(1), s.lowWater) + + require.Error(t, s.guard(1, 999), "conflicting timestamp for an evicted height") + require.Error(t, s.guard(1, 10), "even the original timestamp: absence from the map proves nothing here") + require.NoError(t, s.guard(5, 50), "heights above the low-water mark are unaffected") +} + +// A trailing record with no newline is a crash mid-append; its signature was +// never produced, so it is dropped -- and truncated from disk, or the next +// append would fuse with it into a corrupt record. +func TestOpenState_TruncatesTornTrailingRecord(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, seenFile) + require.NoError(t, os.WriteFile(path, + []byte(`{"height":1,"timestamp":10}`+"\n"+`{"height":2,"tim`), 0o600)) + + s := newTestServer(t, dir, seenLimit) + require.Equal(t, map[uint64]uint64{1: 10}, s.seen) + + require.NoError(t, s.guard(2, 20)) + require.NoError(t, s.seenLog.Close()) + s2 := newTestServer(t, dir, seenLimit) + require.Equal(t, map[uint64]uint64{1: 10, 2: 20}, s2.seen) +} + +// Two timestamps for one height in the log means the guard has already been +// violated; starting up and signing on top of that would be the freeze risk +// itself. +func TestOpenState_RefusesConflictingLog(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, seenFile), + []byte(`{"height":1,"timestamp":10}`+"\n"+`{"height":1,"timestamp":11}`+"\n"), 0o600)) + + s := &server{seen: map[uint64]uint64{}, seenCap: seenLimit} + require.ErrorContains(t, s.openState(dir), "two timestamps") +} + +func TestCheckGenesis(t *testing.T) { + dir := t.TempDir() + + require.NoError(t, checkGenesis(dir, "cbdc-1", "AAAA"), "first run adopts the chain instance") + require.NoError(t, checkGenesis(dir, "cbdc-1", "AAAA"), "same instance restarts fine") + + // Same chain id, different block 1 hash: the exact hole the /status + // chain-id check cannot see. + require.ErrorContains(t, checkGenesis(dir, "cbdc-1", "BBBB"), "RE-GENESISED") + + require.Error(t, checkGenesis(dir, "cbdc-2", "AAAA"), "a state dir must not be shared across chains") + + // -reset-state is the escape hatch once the light client is redeployed. + require.NoError(t, resetState(dir)) + require.NoError(t, checkGenesis(dir, "cbdc-1", "BBBB")) +} diff --git a/cmd/qbftinit/main.go b/cmd/qbftinit/main.go new file mode 100644 index 0000000..b461ccc --- /dev/null +++ b/cmd/qbftinit/main.go @@ -0,0 +1,175 @@ +// Command qbftinit produces the artifact that creates a QBFT light client on +// cbdc-node: an unsigned MsgCreateClient carrying the initial trusted state read +// from the counterparty chain. +// +// The initial trusted state is the one input a light client cannot derive or +// verify — it is asserted, and everything the client ever accepts descends from it. +// So it is read from the counterparty at a height the operator names, and the tool +// prints what was trusted so the choice can be checked before it is signed. +// +// Defaults follow DEC-8: 21-day unbonding on cbdc-node, 14-day trusting period. +// +// Usage: +// +// qbftinit --besu-rpc http://127.0.0.1:8645 --chain-id 1338 \ +// --contract 0x... --height 1234 --signer --out create-client.json +// +// Then sign and broadcast with hnld, and note the client id the tx returns. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/ethereum/go-ethereum/common" + + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + + "github.com/peersyst/cbdc-node/app" + "github.com/peersyst/cbdc-node/x/qbftclient" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// DEC-8: 21-day unbonding, trusting period two thirds of it. The trusting period +// is the window a relayer heartbeat has to hit before the corridor becomes +// unrecoverable, so it is generous by design rather than by oversight. +const defaultTrustingPeriod = 14 * 24 * time.Hour + +func main() { + var ( + besuRPC = flag.String("besu-rpc", "http://127.0.0.1:8645", "counterparty Besu JSON-RPC endpoint") + chainID = flag.Uint64("chain-id", 0, "counterparty EIP-155 chain id") + contract = flag.String("contract", "", "IBC contract address on the counterparty") + height = flag.Uint64("height", 0, "counterparty height to trust initially; 0 is not allowed") + trusting = flag.Duration("trusting-period", defaultTrustingPeriod, "how long a consensus state stays usable") + drift = flag.Duration("max-clock-drift", 10*time.Second, "how far ahead of local time a header may be") + signer = flag.String("signer", "", "bech32 address that will sign on cbdc-node") + evmChain = flag.Uint64("evm-chain-id", 1449999, "cbdc-node EVM chain id, for the tx encoding config") + gasLimit = flag.Uint64("gas", 1_000_000, "gas limit for the generated tx") + outFile = flag.String("out", "create-client.json", "file to write the unsigned tx to") + ) + flag.Parse() + + if *chainID == 0 || *contract == "" || *height == 0 || *signer == "" { + fmt.Fprintln(os.Stderr, "chain-id, contract, height and signer are all required") + flag.Usage() + os.Exit(2) + } + + err := run(context.Background(), params{ + besuRPC: *besuRPC, + chainID: *chainID, + contract: common.HexToAddress(*contract), + height: *height, + trusting: *trusting, + drift: *drift, + signer: *signer, + evmChain: *evmChain, + gasLimit: *gasLimit, + out: *outFile, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +type params struct { + besuRPC string + chainID uint64 + contract common.Address + height uint64 + trusting time.Duration + drift time.Duration + signer string + evmChain uint64 + gasLimit uint64 + out string +} + +func run(ctx context.Context, p params) error { + encCfg := app.MakeEncodingConfig(p.evmChain) + clienttypes.RegisterInterfaces(encCfg.InterfaceRegistry) + qbftclient.RegisterInterfaces(encCfg.InterfaceRegistry) + + chain, err := besu.Dial(ctx, p.besuRPC) + if err != nil { + return err + } + defer chain.Close() + + // HeaderByNumber checks that our encoding of this header hashes to what the + // node reports, so a mismatch surfaces here — before a client is created around + // a header we cannot reproduce. + header, err := chain.HeaderByNumber(ctx, p.height) + if err != nil { + return err + } + + consensusState, err := types.NewConsensusState(header) + if err != nil { + return err + } + if err := consensusState.ValidateBasic(); err != nil { + return err + } + + // p.trusting and p.drift are operator-supplied flag durations, positive by + // construction and measured in hours; the header time comes from a node this + // tool just read. None of the three crosses a trust boundary, which is what + // G115 is for. + //nolint:gosec // operator flags and a locally-read header, not wire input + clientState := &types.ClientState{ + ChainId: p.chainID, + TrustingPeriod: uint64(p.trusting / time.Second), + MaxClockDrift: uint64(p.drift / time.Second), + LatestHeight: p.height, + IbcContractAddress: p.contract.Bytes(), + } + if err := clientState.Validate(); err != nil { + return err + } + + msg, err := clienttypes.NewMsgCreateClient(clientState, consensusState, p.signer) + if err != nil { + return fmt.Errorf("build MsgCreateClient: %w", err) + } + + txBuilder := encCfg.TxConfig.NewTxBuilder() + if err := txBuilder.SetMsgs(msg); err != nil { + return fmt.Errorf("set msgs: %w", err) + } + txBuilder.SetGasLimit(p.gasLimit) + + bz, err := encCfg.TxConfig.TxJSONEncoder()(txBuilder.GetTx()) + if err != nil { + return fmt.Errorf("encode tx: %w", err) + } + if err := os.WriteFile(p.out, bz, 0o600); err != nil { + return fmt.Errorf("write %s: %w", p.out, err) + } + + // Print what is being trusted. This is the one input nobody can check later: + // once the client exists, every header it accepts descends from this set. + validators := consensusState.ValidatorAddresses() + fmt.Printf("counterparty chain %d, contract %s\n", p.chainID, p.contract) + //nolint:gosec // display only, from a header this tool just read + fmt.Printf("trusted height %d, block time %s\n", p.height, time.Unix(int64(header.Time), 0).UTC()) + fmt.Printf("state root %s\n", consensusState.Root()) + fmt.Printf("trusting %s (clock drift %s)\n", p.trusting, p.drift) + fmt.Printf("validators %d, quorum %d\n", len(validators), types.RequiredQuorum(len(validators))) + for i, v := range validators { + fmt.Printf(" [%d] %s\n", i, v) + } + if len(validators) < 4 { + fmt.Printf("\nWARNING: %d validators tolerates 0 faults (QBFT needs n >= 3f+1).\n"+ + " Creating a client against this chain is an explicit acceptance, not a default.\n", + len(validators)) + } + fmt.Printf("\nwrote unsigned MsgCreateClient to %s\n", p.out) + return nil +} diff --git a/cmd/qbftproofapi/evm.go b/cmd/qbftproofapi/evm.go new file mode 100644 index 0000000..5a0d257 --- /dev/null +++ b/cmd/qbftproofapi/evm.go @@ -0,0 +1,343 @@ +package main + +// EVM-side encoding: decoding SendPacket events out of Besu receipts and +// packing the ICS26Router calldata cosmos/ibc-relayer submits verbatim. + +import ( + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" +) + +// routerABIJSON is the slice of solidity-ibc-eureka's ICS26Router this shim +// needs, copied from /tmp/eureka abi output (contracts/ICS26Router.sol). The +// SendPacket event doubles as the decoder for inbound receipts. +const routerABIJSON = `[ + {"type":"event","name":"SendPacket","anonymous":false,"inputs":[ + {"name":"clientId","type":"string","indexed":true}, + {"name":"sequence","type":"uint256","indexed":true}, + {"name":"packet","type":"tuple","indexed":false,"components":[ + {"name":"sequence","type":"uint64"}, + {"name":"sourceClient","type":"string"}, + {"name":"destClient","type":"string"}, + {"name":"timeoutTimestamp","type":"uint64"}, + {"name":"payloads","type":"tuple[]","components":[ + {"name":"sourcePort","type":"string"}, + {"name":"destPort","type":"string"}, + {"name":"version","type":"string"}, + {"name":"encoding","type":"string"}, + {"name":"value","type":"bytes"} + ]} + ]} + ]}, + {"type":"function","name":"updateClient","inputs":[ + {"name":"clientId","type":"string"}, + {"name":"updateMsg","type":"bytes"} + ]}, + {"type":"function","name":"recvPacket","inputs":[ + {"name":"msg_","type":"tuple","components":[ + {"name":"packet","type":"tuple","components":[ + {"name":"sequence","type":"uint64"}, + {"name":"sourceClient","type":"string"}, + {"name":"destClient","type":"string"}, + {"name":"timeoutTimestamp","type":"uint64"}, + {"name":"payloads","type":"tuple[]","components":[ + {"name":"sourcePort","type":"string"}, + {"name":"destPort","type":"string"}, + {"name":"version","type":"string"}, + {"name":"encoding","type":"string"}, + {"name":"value","type":"bytes"} + ]} + ]}, + {"name":"proofCommitment","type":"bytes"}, + {"name":"proofHeight","type":"tuple","components":[ + {"name":"revisionNumber","type":"uint64"}, + {"name":"revisionHeight","type":"uint64"} + ]} + ]} + ]}, + {"type":"event","name":"WriteAcknowledgement","anonymous":false,"inputs":[ + {"name":"clientId","type":"string","indexed":true}, + {"name":"sequence","type":"uint256","indexed":true}, + {"name":"packet","type":"tuple","indexed":false,"components":[ + {"name":"sequence","type":"uint64"}, + {"name":"sourceClient","type":"string"}, + {"name":"destClient","type":"string"}, + {"name":"timeoutTimestamp","type":"uint64"}, + {"name":"payloads","type":"tuple[]","components":[ + {"name":"sourcePort","type":"string"}, + {"name":"destPort","type":"string"}, + {"name":"version","type":"string"}, + {"name":"encoding","type":"string"}, + {"name":"value","type":"bytes"} + ]} + ]}, + {"name":"acknowledgements","type":"bytes[]","indexed":false} + ]}, + {"type":"function","name":"ackPacket","inputs":[ + {"name":"msg_","type":"tuple","components":[ + {"name":"packet","type":"tuple","components":[ + {"name":"sequence","type":"uint64"}, + {"name":"sourceClient","type":"string"}, + {"name":"destClient","type":"string"}, + {"name":"timeoutTimestamp","type":"uint64"}, + {"name":"payloads","type":"tuple[]","components":[ + {"name":"sourcePort","type":"string"}, + {"name":"destPort","type":"string"}, + {"name":"version","type":"string"}, + {"name":"encoding","type":"string"}, + {"name":"value","type":"bytes"} + ]} + ]}, + {"name":"acknowledgement","type":"bytes"}, + {"name":"proofAcked","type":"bytes"}, + {"name":"proofHeight","type":"tuple","components":[ + {"name":"revisionNumber","type":"uint64"}, + {"name":"revisionHeight","type":"uint64"} + ]} + ]} + ]}, + {"type":"function","name":"timeoutPacket","inputs":[ + {"name":"msg_","type":"tuple","components":[ + {"name":"packet","type":"tuple","components":[ + {"name":"sequence","type":"uint64"}, + {"name":"sourceClient","type":"string"}, + {"name":"destClient","type":"string"}, + {"name":"timeoutTimestamp","type":"uint64"}, + {"name":"payloads","type":"tuple[]","components":[ + {"name":"sourcePort","type":"string"}, + {"name":"destPort","type":"string"}, + {"name":"version","type":"string"}, + {"name":"encoding","type":"string"}, + {"name":"value","type":"bytes"} + ]} + ]}, + {"name":"proofTimeout","type":"bytes"}, + {"name":"proofHeight","type":"tuple","components":[ + {"name":"revisionNumber","type":"uint64"}, + {"name":"revisionHeight","type":"uint64"} + ]} + ]} + ]}, + {"type":"function","name":"multicall","inputs":[ + {"name":"data","type":"bytes[]"} + ]} +]` + +var routerABI = func() abi.ABI { + parsed, err := abi.JSON(strings.NewReader(routerABIJSON)) + if err != nil { + panic(fmt.Sprintf("routerABIJSON does not parse: %v", err)) + } + return parsed +}() + +// solPayload / solPacket mirror IICS26RouterMsgs field order; go-ethereum's +// abi packer matches struct fields to tuple components by name. +type solPayload struct { + SourcePort string + DestPort string + Version string + Encoding string + Value []byte +} + +type solPacket struct { + Sequence uint64 + SourceClient string + DestClient string + TimeoutTimestamp uint64 + Payloads []solPayload +} + +type solHeight struct { + RevisionNumber uint64 + RevisionHeight uint64 +} + +type solMsgRecvPacket struct { + Packet solPacket + ProofCommitment []byte + ProofHeight solHeight +} + +type solMsgAckPacket struct { + Packet solPacket + Acknowledgement []byte + ProofAcked []byte + ProofHeight solHeight +} + +type solMsgTimeoutPacket struct { + Packet solPacket + ProofTimeout []byte + ProofHeight solHeight +} + +// toSolPacket converts the protobuf packet cbdc-node emitted into the tuple +// ICS26Router.recvPacket expects. Same mapping as cmd/packetconv -to-solidity. +func toSolPacket(pk channeltypesv2.Packet) solPacket { + out := solPacket{ + Sequence: pk.Sequence, + SourceClient: pk.SourceClient, + DestClient: pk.DestinationClient, + TimeoutTimestamp: pk.TimeoutTimestamp, + } + for _, pl := range pk.Payloads { + out.Payloads = append(out.Payloads, solPayload{ + SourcePort: pl.SourcePort, + DestPort: pl.DestinationPort, + Version: pl.Version, + Encoding: pl.Encoding, + Value: pl.Value, + }) + } + return out +} + +// fromSolPacket is the inverse of toSolPacket, shared by the SendPacket and +// WriteAcknowledgement decoders. Same mapping as cmd/packetconv. +func fromSolPacket(sol solPacket) channeltypesv2.Packet { + packet := channeltypesv2.Packet{ + Sequence: sol.Sequence, + SourceClient: sol.SourceClient, + DestinationClient: sol.DestClient, + TimeoutTimestamp: sol.TimeoutTimestamp, + } + for _, p := range sol.Payloads { + packet.Payloads = append(packet.Payloads, channeltypesv2.Payload{ + SourcePort: p.SourcePort, + DestinationPort: p.DestPort, + Version: p.Version, + Encoding: p.Encoding, + Value: p.Value, + }) + } + return packet +} + +// fromSendPacketLog decodes one SendPacket event payload into the protobuf +// packet MsgRecvPacket carries. +func fromSendPacketLog(data []byte) (channeltypesv2.Packet, error) { + out, err := routerABI.Unpack("SendPacket", data) + if err != nil { + return channeltypesv2.Packet{}, fmt.Errorf("unpack SendPacket: %w", err) + } + var sol solPacket + // The single non-indexed argument is the packet tuple; go-ethereum decodes + // it into an anonymous struct, so re-marshal through the ABI argument set. + err = routerABI.Events["SendPacket"].Inputs.NonIndexed().Copy(&struct { + Packet *solPacket + }{Packet: &sol}, out) + if err != nil { + return channeltypesv2.Packet{}, fmt.Errorf("copy SendPacket: %w", err) + } + return fromSolPacket(sol), nil +} + +// fromWriteAckLog decodes one WriteAcknowledgement event payload into the +// packet plus its RAW app acknowledgements — the acknowledgements array +// elements themselves. MsgAcknowledgement wants exactly these bytes: ibc-go +// recomputes the ack commitment from them, so wrapping or re-encoding here +// would fail verification on cbdc-node rather than clear the commitment. +func fromWriteAckLog(data []byte) (channeltypesv2.Packet, [][]byte, error) { + out, err := routerABI.Unpack("WriteAcknowledgement", data) + if err != nil { + return channeltypesv2.Packet{}, nil, fmt.Errorf("unpack WriteAcknowledgement: %w", err) + } + var ( + sol solPacket + acks [][]byte + ) + err = routerABI.Events["WriteAcknowledgement"].Inputs.NonIndexed().Copy(&struct { + Packet *solPacket + Acknowledgements *[][]byte + }{Packet: &sol, Acknowledgements: &acks}, out) + if err != nil { + return channeltypesv2.Packet{}, nil, fmt.Errorf("copy WriteAcknowledgement: %w", err) + } + if len(acks) == 0 { + return channeltypesv2.Packet{}, nil, fmt.Errorf("WriteAcknowledgement for seq %d carries no acknowledgements", sol.Sequence) + } + return fromSolPacket(sol), acks, nil +} + +// multicallRecv packs multicall([updateClient(dstClient, stateProof), +// recvPacket(...)...]) — the exact calldata shape the relayer's EVM path +// expects back from proof-api: element 0 advances the light client, the rest +// deliver packets proved at attestedHeight. +func multicallRecv(dstClient string, stateProof []byte, packets []channeltypesv2.Packet, packetProof []byte, attestedHeight uint64) ([]byte, error) { + update, err := routerABI.Pack("updateClient", dstClient, stateProof) + if err != nil { + return nil, fmt.Errorf("pack updateClient: %w", err) + } + calls := [][]byte{update} + for _, pk := range packets { + recv, err := routerABI.Pack("recvPacket", solMsgRecvPacket{ + Packet: toSolPacket(pk), + ProofCommitment: packetProof, + ProofHeight: solHeight{RevisionNumber: 0, RevisionHeight: attestedHeight}, + }) + if err != nil { + return nil, fmt.Errorf("pack recvPacket seq %d: %w", pk.Sequence, err) + } + calls = append(calls, recv) + } + return routerABI.Pack("multicall", calls) +} + +// multicallAck packs multicall([updateClient(dstClient, stateProof), +// ackPacket(...)...]) — same shape as multicallRecv, but each element carries +// the RAW app ack cbdc-node wrote (acks[i] pairs with packets[i]) plus the +// membership proof of its ack path. The router recomputes the ack commitment +// from the ack bytes and checks it against the attested value, then deletes +// the send commitment — the entry that held the escrow. +func multicallAck(dstClient string, stateProof []byte, packets []channeltypesv2.Packet, acks [][]byte, packetProof []byte, attestedHeight uint64) ([]byte, error) { + update, err := routerABI.Pack("updateClient", dstClient, stateProof) + if err != nil { + return nil, fmt.Errorf("pack updateClient: %w", err) + } + calls := [][]byte{update} + for i, pk := range packets { + ack, err := routerABI.Pack("ackPacket", solMsgAckPacket{ + Packet: toSolPacket(pk), + Acknowledgement: acks[i], + ProofAcked: packetProof, + ProofHeight: solHeight{RevisionNumber: 0, RevisionHeight: attestedHeight}, + }) + if err != nil { + return nil, fmt.Errorf("pack ackPacket seq %d: %w", pk.Sequence, err) + } + calls = append(calls, ack) + } + return routerABI.Pack("multicall", calls) +} + +// multicallTimeout packs multicall([updateClient(dstClient, stateProof), +// timeoutPacket(...)...]). The proof here is one of NON-membership — the +// attested set carries {receiptPathHash, bytes32(0)} per packet — and the +// router only refunds if the trusted timestamp at attestedHeight is already +// past the packet's timeout (ICS26Router.timeoutPacket), so attesting too +// early merely reverts instead of releasing escrow. +func multicallTimeout(dstClient string, stateProof []byte, packets []channeltypesv2.Packet, absenceProof []byte, attestedHeight uint64) ([]byte, error) { + update, err := routerABI.Pack("updateClient", dstClient, stateProof) + if err != nil { + return nil, fmt.Errorf("pack updateClient: %w", err) + } + calls := [][]byte{update} + for _, pk := range packets { + tout, err := routerABI.Pack("timeoutPacket", solMsgTimeoutPacket{ + Packet: toSolPacket(pk), + ProofTimeout: absenceProof, + ProofHeight: solHeight{RevisionNumber: 0, RevisionHeight: attestedHeight}, + }) + if err != nil { + return nil, fmt.Errorf("pack timeoutPacket seq %d: %w", pk.Sequence, err) + } + calls = append(calls, tout) + } + return routerABI.Pack("multicall", calls) +} diff --git a/cmd/qbftproofapi/guard.go b/cmd/qbftproofapi/guard.go new file mode 100644 index 0000000..4967821 --- /dev/null +++ b/cmd/qbftproofapi/guard.go @@ -0,0 +1,302 @@ +package main + +// The two safety checks that stand between a stateless attestor and the two +// irreversible outcomes it can be talked into. +// +// # WHY THESE LIVE HERE +// +// cosmos/ibc-attestor (DEC-32) keeps no record of what it has signed and cannot +// tell one instance of a chain from another. cmd/qbftattestor carried both +// guards internally; adopting upstream drops them. They ran briefly as a +// separate proxy, which was a hop in the money path earning its keep only +// because it could not be bypassed by configuration. This process is the sole +// caller of the attestor, so that property costs nothing to keep here and one +// fewer process to run. +// +// ⚠️ If a second caller ever reaches the attestor -- upstream's cosmos-to-eth +// aggregator at m-of-n is the realistic case -- these checks no longer cover +// it, and they must move back in front of the attestor rather than beside it. +// +// # THE TWO OUTCOMES +// +// 1. FREEZE. AttestationLightClient records every height's timestamp forever +// and sets isFrozen on seeing a second, different one, with no unfreeze. +// guardHeight makes that input impossible to request. +// +// 2. WRONGFUL REFUND. A receipt-absence attestation is authority to release +// escrow. provenAbsent re-establishes absence from the store directly +// rather than trusting an app-level boolean. +// +// Order is the whole design for (1): the record is fsync'd BEFORE the attestor +// is asked. A signature is a bearer instrument -- once a conflicting one exists, +// anyone who ever sees it can freeze the client with it -- so the only useful +// guard is one that prevents it being produced, not one that notices afterwards. + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "os" + "path/filepath" + "sync" + + rpcclient "github.com/cometbft/cometbft/rpc/client" +) + +const ( + seenFile = "seen.jsonl" + genesisFile = "genesis.json" +) + +type seenRecord struct { + Height uint64 `json:"height"` + Timestamp uint64 `json:"timestamp"` +} + +// genesisMarker pins the guard state to one chain INSTANCE. Block 1's hash +// changes on every re-genesis even when the chain id is reused, which is exactly +// the case a chain-id comparison cannot catch. +type genesisMarker struct { + ChainID string `json:"cbdc_chain_id"` + Block1Hash string `json:"block1_hash"` +} + +type guardState struct { + mu sync.Mutex + seen map[uint64]uint64 + seenLog *os.File +} + +// guardHeight durably records (height, ts) and returns nil only when asking for +// an attestation of that pair cannot contradict anything already released. +func (s *server) guardHeight(height, ts uint64) error { + g := s.guard + g.mu.Lock() + defer g.mu.Unlock() + + if prev, ok := g.seen[height]; ok { + if prev != ts { + return fmt.Errorf( + "REFUSING: height %d was already attested as %d, this node now reads %d -- "+ + "releasing both would freeze the light client permanently, with no unfreeze", + height, prev, ts) + } + return nil // same pair; re-requesting it is idempotent + } + + b, err := json.Marshal(seenRecord{Height: height, Timestamp: ts}) + if err != nil { + return err + } + if _, err := g.seenLog.Write(append(b, '\n')); err != nil { + return fmt.Errorf("guard not durable, refusing to proceed: %w", err) + } + // The Sync is the point: no attestation may be REQUESTED whose record a + // crash could forget. + if err := g.seenLog.Sync(); err != nil { + return fmt.Errorf("guard not durable, refusing to proceed: %w", err) + } + g.seen[height] = ts + return nil +} + +// cometHeight converts a proto/ABI uint64 height into the int64 CometBFT's RPC +// takes, refusing rather than wrapping. +// +// 🔴 A wrapped value is not a rounding error here. A NEGATIVE height means +// "latest" to the CometBFT RPC, so an overflowed height would silently anchor a +// guard record or an absence proof to a FLOATING height instead of the fixed one +// the caller asked about. Both outcomes are irreversible -- one can freeze the +// light client, the other releases escrow -- so this refuses instead. +func cometHeight(height uint64) (int64, error) { + if height > math.MaxInt64 { + return 0, fmt.Errorf("height %d exceeds the int64 range CometBFT accepts", height) + } + return int64(height), nil +} + +// uint64Height converts a CometBFT int64 height into the uint64 the proto and +// ABI sides carry. The mirror of cometHeight, refused for the mirror reason: a +// negative height widened into uint64 becomes an enormous number, and this one +// is on its way into the guard log and an attestation. +func uint64Height(h int64) (uint64, error) { + if h < 0 { + return 0, fmt.Errorf("node reported a negative height (%d), which cannot be attested", h) + } + return uint64(h), nil +} + +// blockTimeSeconds reads a block's timestamp in unix seconds. +func (s *server) blockTimeSeconds(ctx context.Context, height uint64) (uint64, error) { + h, err := cometHeight(height) + if err != nil { + return 0, err + } + blk, err := s.cbdc.Block(ctx, &h) + if err != nil { + return 0, err + } + if blk.Block == nil { + return 0, fmt.Errorf("no block at height %d", height) + } + // A pre-epoch block time would wrap into an enormous uint64 and be recorded + // as this height's timestamp forever. No real chain produces one; a node + // that does is malfunctioning, and this guard's whole job is to not write + // what it cannot stand behind. + sec := blk.Block.Header.Time.Unix() + if sec < 0 { + return 0, fmt.Errorf("block %d reports a pre-1970 timestamp (%d), refusing to attest it", height, sec) + } + return uint64(sec), nil +} + +// provenAbsent returns nil only when cbdc-node has POSITIVELY shown that no +// value exists at path as of height. This backs the signature that releases the +// counterparty's escrow, so every ambiguous outcome lands on the error side. +// +// 🔴 The trap: the SDK's IAVL store answers a query for a PRUNED OR NONEXISTENT +// version with code 0 and an empty value -- byte-for-byte identical to genuine +// absence. Deriving absence from an app-level `received=false` therefore turns a +// question the node could not answer into a refund. (cosmos/ibc-attestor does +// exactly that; its own audit fix, branch fix/audit-m2-cosmos-receipt-app-code, +// is unmerged as of 2026-08-14 and only adds a code check -- it still demands +// neither a proof nor a matching height. So this check stays even after that +// lands.) +// +// The countermeasure is Prove. With proving requested the store must build an +// absence proof at exactly that version, and rootmulti turns "version not +// available" into a hard error instead of an empty success. The acceptance test +// is therefore four-part: code 0, AND the response echoes the height asked +// about, AND proof ops are present, AND the value is empty. The IAVL proof +// itself is not verified -- this node is our trust anchor either way, the same +// one every membership attestation reads -- what Prove buys is disambiguation. +func (s *server) provenAbsent(ctx context.Context, path []byte, height uint64) error { + // Height 0 means "latest" to the RPC and proving is rejected below height 2; + // a floating height must never anchor an absence claim. + if height < 2 { + return fmt.Errorf("refusing at height %d: absence is only meaningful at a fixed height above 1", height) + } + + h, err := cometHeight(height) + if err != nil { + return err + } + res, err := s.cbdc.ABCIQueryWithOptions(ctx, "store/ibc/key", path, + rpcclient.ABCIQueryOptions{Height: h, Prove: true}) + if err != nil { + return fmt.Errorf("node could not answer, which proves nothing: %w", err) + } + resp := res.Response + if resp.Code != 0 { + return fmt.Errorf("abci query failed (code %d): %s -- a failed query is not absence", resp.Code, resp.Log) + } + // Compared as int64 against the checked height, so neither side is converted. + if resp.Height != h { + return fmt.Errorf("node answered for height %d, not the requested %d -- refusing to attest absence at a height it did not evaluate", resp.Height, height) + } + if resp.ProofOps == nil || len(resp.ProofOps.Ops) == 0 { + return fmt.Errorf("no proof ops at height %d -- an unproven empty answer is indistinguishable from a pruned or missing version", height) + } + if len(resp.Value) != 0 { + return fmt.Errorf("a value EXISTS at that path (0x%s) and height %d -- the packet WAS received; "+ + "attesting its absence would release escrow that must not be released", hex.EncodeToString(path), height) + } + return nil +} + +// openGuardState loads the durable log and reopens it for appending. +// +// A trailing record with no newline is a crash mid-append: its attestation was +// never requested (guardHeight persists before asking), so it is truncated +// away -- truncated, not just skipped, or the next append would fuse with it +// into a corrupt record that hides every height recorded after it. Anything +// else that fails to parse means the log was edited or shared between +// processes, and attesting on top of an untrusted log is the freeze risk +// itself, so it is fatal. So is a log already holding two timestamps for one +// height: the conflicting signature may already exist. +func openGuardState(dir string) (*guardState, error) { + g := &guardState{seen: map[uint64]uint64{}} + path := filepath.Join(dir, seenFile) + + raw, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + rest, goodLen := raw, 0 + for len(rest) > 0 { + nl := bytes.IndexByte(rest, '\n') + if nl < 0 { + log.Printf("guard: truncating torn trailing record %q from %s (crash mid-append; its attestation was never requested)", rest, path) + if err := os.Truncate(path, int64(goodLen)); err != nil { + return nil, fmt.Errorf("truncate torn record: %w", err) + } + break + } + line := rest[:nl] + rest = rest[nl+1:] + goodLen += nl + 1 + var rec seenRecord + if err := json.Unmarshal(line, &rec); err != nil { + return nil, fmt.Errorf("corrupt record in %s: %q: %v", path, line, err) + } + if prev, ok := g.seen[rec.Height]; ok && prev != rec.Timestamp { + return nil, fmt.Errorf( + "%s records two timestamps for height %d (%d then %d): the freeze guard has already been violated once; do NOT attest against the existing light client", + path, rec.Height, prev, rec.Timestamp) + } + g.seen[rec.Height] = rec.Timestamp + } + + g.seenLog, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) + if err != nil { + return nil, err + } + return g, nil +} + +// checkGenesis records the chain instance on first run and refuses to start +// against a different one thereafter. This is the re-genesis detector: after a +// re-genesis the light client must be redeployed before anything is attested, +// and refusing to start is how that gets noticed before a freeze rather than +// after one. +func checkGenesis(dir, chainID, block1 string) error { + path := filepath.Join(dir, genesisFile) + want := genesisMarker{ChainID: chainID, Block1Hash: block1} + + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + out, _ := json.Marshal(want) + return os.WriteFile(path, out, 0o600) + } else if err != nil { + return err + } + var have genesisMarker + if err := json.Unmarshal(b, &have); err != nil { + return fmt.Errorf("%s is unreadable: %w", path, err) + } + if have.ChainID != want.ChainID || have.Block1Hash != want.Block1Hash { + return fmt.Errorf( + "this guard state belongs to chain %s block1=%s, but the node is %s block1=%s"+ + " -- a re-genesis restarts heights the light client already holds timestamps"+ + " for, so attesting against it freezes the client permanently; redeploy the"+ + " AttestationLightClient and migrateClient it behind the existing client id,"+ + " THEN delete %s", + have.ChainID, have.Block1Hash, want.ChainID, want.Block1Hash, dir) + } + return nil +} + +// block1Hash identifies the chain instance. +func (s *server) block1Hash(ctx context.Context) (string, error) { + one := int64(1) + blk, err := s.cbdc.Block(ctx, &one) + if err != nil { + return "", err + } + return blk.BlockID.Hash.String(), nil +} diff --git a/cmd/qbftproofapi/guard_test.go b/cmd/qbftproofapi/guard_test.go new file mode 100644 index 0000000..0dea838 --- /dev/null +++ b/cmd/qbftproofapi/guard_test.go @@ -0,0 +1,201 @@ +package main + +// Integration checks for the two guards, against a live cbdc-node. They skip +// when no node is reachable, so `go test ./...` stays green on a bare checkout. +// +// These are worth running against a real node rather than a mock: the failure +// they protect against -- an unanswerable query reading as genuine absence -- is +// a property of how the SDK's IAVL store answers, and a mock would simply +// reproduce whatever behavior was assumed when writing it. + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" +) + +const testRPC = "http://127.0.0.1:26657" + +func liveServer(t *testing.T) (*server, uint64) { + t.Helper() + cli, err := rpchttp.New(testRPC, "/websocket") + if err != nil { + t.Skipf("no cbdc-node at %s: %v", testRPC, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := cli.Status(ctx) + if err != nil { + t.Skipf("cbdc-node at %s not answering: %v", testRPC, err) + } + //nolint:gosec // a CometBFT height is positive; this is a test fixture + return &server{cbdc: cli}, uint64(st.SyncInfo.LatestBlockHeight) +} + +// A receipt that EXISTS must never be attested absent: that signature releases +// escrow for a packet that was delivered. +func TestProvenAbsent_RefusesWhenReceiptExists(t *testing.T) { + s, tip := liveServer(t) + path := attestor.ReceiptPath("qbftclient-0", 1) // seq 1 was received + err := s.provenAbsent(context.Background(), path, tip-10) + if err == nil { + t.Fatal("provenAbsent accepted a receipt path that HAS a value -- this would refund a delivered packet") + } + t.Logf("correctly refused: %v", err) +} + +// A sequence that was never received must be provable as absent, or the refund +// path is dead and escrow can never be released for a genuine timeout. +func TestProvenAbsent_AcceptsGenuineAbsence(t *testing.T) { + s, tip := liveServer(t) + path := attestor.ReceiptPath("qbftclient-0", 999999) + if err := s.provenAbsent(context.Background(), path, tip-10); err != nil { + t.Fatalf("provenAbsent rejected a genuinely absent receipt: %v", err) + } +} + +// An unproven or unanswerable height must not read as absence. Height 1 stands +// in for the whole class: proving is rejected there, and it is the boundary the +// code special-cases. +func TestProvenAbsent_RefusesUnprovableHeight(t *testing.T) { + s, _ := liveServer(t) + path := attestor.ReceiptPath("qbftclient-0", 999999) + for _, h := range []uint64{0, 1} { + if err := s.provenAbsent(context.Background(), path, h); err == nil { + t.Fatalf("provenAbsent accepted height %d -- absence there proves nothing", h) + } + } +} + +// The freeze guard must reject a second, DIFFERENT timestamp for one height, +// and must be idempotent for a repeat of the same pair. +func TestGuardHeight_RefusesConflictingTimestamp(t *testing.T) { + dir := t.TempDir() + g, err := openGuardState(dir) + if err != nil { + t.Fatal(err) + } + s := &server{guard: g} + + if err := s.guardHeight(100, 1700000000); err != nil { + t.Fatalf("first attestation refused: %v", err) + } + if err := s.guardHeight(100, 1700000000); err != nil { + t.Fatalf("repeat of the SAME pair must be idempotent, got: %v", err) + } + if err := s.guardHeight(100, 1700000001); err == nil { + t.Fatal("guard allowed a second, different timestamp for height 100 -- that freezes the client permanently") + } +} + +// The record must survive a restart: the freeze happens on the SECOND +// signature, so a guard that forgets across restarts protects nothing in the +// one scenario it exists for. +func TestGuardHeight_SurvivesRestart(t *testing.T) { + dir := t.TempDir() + g, err := openGuardState(dir) + if err != nil { + t.Fatal(err) + } + if err := (&server{guard: g}).guardHeight(42, 1700000000); err != nil { + t.Fatal(err) + } + g.seenLog.Close() + + reopened, err := openGuardState(dir) // simulate a restart + if err != nil { + t.Fatal(err) + } + if err := (&server{guard: reopened}).guardHeight(42, 1700000999); err == nil { + t.Fatal("guard forgot height 42 across a restart -- the conflict it exists to stop would go through") + } +} + +// A crash mid-append leaves a torn trailing record. It must be truncated on +// load -- not just skipped -- or the next append fuses with it into a corrupt +// line that silently blinds the guard to every height recorded after it. +func TestOpenGuardState_TruncatesTornTail(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, seenFile) + torn := `{"height":1,"timestamp":100}` + "\n" + `{"height":2,"timesta` + if err := os.WriteFile(path, []byte(torn), 0o600); err != nil { + t.Fatal(err) + } + + g, err := openGuardState(dir) + if err != nil { + t.Fatalf("a torn tail is a normal crash artifact, not corruption: %v", err) + } + s := &server{guard: g} + if err := s.guardHeight(1, 999); err == nil { + t.Fatal("guard forgot height 1, which was fully recorded before the torn tail") + } + if err := s.guardHeight(3, 300); err != nil { + t.Fatal(err) + } + g.seenLog.Close() + + reopened, err := openGuardState(dir) + if err != nil { + t.Fatalf("log corrupt after appending over a torn tail -- truncation did not happen: %v", err) + } + if err := (&server{guard: reopened}).guardHeight(3, 999); err == nil { + t.Fatal("height 3 forgotten across restart: its record fused with the torn tail") + } +} + +// A log damaged before truncation existed -- a torn record already fused with a +// later append -- cannot say which heights it lost, so starting on it must be +// refused, not silently read up to the damage. +func TestOpenGuardState_RefusesFusedLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, seenFile) + fused := `{"height":1,"timestamp":100}` + "\n" + + `{"height":2,"timesta{"height":3,"timestamp":300}` + "\n" + + `{"height":4,"timestamp":400}` + "\n" + if err := os.WriteFile(path, []byte(fused), 0o600); err != nil { + t.Fatal(err) + } + if _, err := openGuardState(dir); err == nil { + t.Fatal("openGuardState accepted a fused corrupt line -- it would forget every height recorded after it") + } +} + +// A log that already records two timestamps for one height means the guard was +// violated before this start; the conflicting attestation may already exist. +func TestOpenGuardState_RefusesViolatedLog(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, seenFile) + violated := `{"height":7,"timestamp":100}` + "\n" + `{"height":7,"timestamp":200}` + "\n" + if err := os.WriteFile(path, []byte(violated), 0o600); err != nil { + t.Fatal(err) + } + if _, err := openGuardState(dir); err == nil { + t.Fatal("openGuardState accepted a log holding two timestamps for height 7 -- the guard was already violated") + } +} + +// A re-genesis keeps the chain id and changes block 1's hash. Starting against +// it must be refused, because heights the light client already holds timestamps +// for are about to be re-produced with different ones. +func TestCheckGenesis_DetectsRegenesis(t *testing.T) { + dir := t.TempDir() + if err := checkGenesis(dir, "cbdc-honduras_5040000-1", "AAAA"); err != nil { + t.Fatalf("first run should record, not refuse: %v", err) + } + if err := checkGenesis(dir, "cbdc-honduras_5040000-1", "AAAA"); err != nil { + t.Fatalf("same instance should be accepted: %v", err) + } + if err := checkGenesis(dir, "cbdc-honduras_5040000-1", "BBBB"); err == nil { + t.Fatal("same chain id with a different block 1 hash is a re-genesis and must be refused") + } + if _, err := os.Stat(dir + "/" + genesisFile); err != nil { + t.Fatalf("genesis marker not written: %v", err) + } +} diff --git a/cmd/qbftproofapi/inbound.go b/cmd/qbftproofapi/inbound.go new file mode 100644 index 0000000..f90751f --- /dev/null +++ b/cmd/qbftproofapi/inbound.go @@ -0,0 +1,291 @@ +package main + +// Inbound: everything proved OUT OF Besu and delivered TO cbdc-node as the +// unsigned TxBody the relayer signs and broadcasts. That is three message +// kinds, not one: receives of Besu-sent packets (commitment PRESENT), and — +// because acks and timeouts travel against the packet — acks (ack PRESENT) +// and timeouts (receipt ABSENT) of cbdc-node-sent packets. All MPT proofs out +// of the router's storage, built via relaytx so this shim and cmd/qbftrelay +// cannot drift apart. + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdktx "github.com/cosmos/cosmos-sdk/types/tx" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + host "github.com/cosmos/ibc-go/v10/modules/core/24-host" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/relaytx" + qbfttypes "github.com/peersyst/cbdc-node/x/qbftclient/types" + + "github.com/peersyst/cbdc-node/x/qbftclient/proofapipb" +) + +func (s *server) inbound(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + rpcCli, err := rpc.DialContext(ctx, s.cfg.besuRPC) + if err != nil { + return nil, fmt.Errorf("dial besu: %w", err) + } + defer rpcCli.Close() + eth := ethclient.NewClient(rpcCli) + chain := besu.New(rpcCli) + + srcClient := req.GetSrcClientId() + if srcClient == "" { + srcClient = s.cfg.besuClient + } + dstClient := req.GetDstClientId() + if dstClient == "" { + dstClient = s.cfg.cbdcClient + } + + packets, err := s.packetsFromReceipts(ctx, eth, req.GetSourceTxIds(), srcClient) + if err != nil { + return nil, err + } + if len(packets) == 0 { + return nil, fmt.Errorf("no SendPacket events for client %s in the given transactions", srcClient) + } + + msgs, target, err := s.updatesToHead(ctx, chain, eth, dstClient) + if err != nil { + return nil, err + } + recvs, err := relaytx.RecvMsgs(ctx, chain, s.cfg.router, s.cdc, packets, target, s.cfg.signer) + if err != nil { + return nil, err + } + return txBodyResponse(append(msgs, recvs...)) +} + +// inboundAck returns acknowledgements for packets cbdc-node SENT: Besu wrote +// the ack at receive time, and cbdc-node's send commitment — with the escrow +// behind it — stays set until a MsgAcknowledgement proves that ack back. The +// relayer flips (src,dst) for acks, so source_tx_ids are the Besu write-ack +// transactions and the client to update still arrives as dst_client_id. +func (s *server) inboundAck(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + rpcCli, err := rpc.DialContext(ctx, s.cfg.besuRPC) + if err != nil { + return nil, fmt.Errorf("dial besu: %w", err) + } + defer rpcCli.Close() + eth := ethclient.NewClient(rpcCli) + chain := besu.New(rpcCli) + + srcClient := req.GetSrcClientId() + if srcClient == "" { + srcClient = s.cfg.besuClient + } + dstClient := req.GetDstClientId() + if dstClient == "" { + dstClient = s.cfg.cbdcClient + } + + packets, acks, err := s.acksFromReceipts(ctx, eth, req.GetSourceTxIds(), srcClient) + if err != nil { + return nil, err + } + if len(packets) == 0 { + return nil, fmt.Errorf("no WriteAcknowledgement events for client %s in the given transactions", srcClient) + } + + msgs, target, err := s.updatesToHead(ctx, chain, eth, dstClient) + if err != nil { + return nil, err + } + ackMsgs, err := relaytx.AckMsgs(ctx, chain, s.cfg.router, s.cdc, packets, acks, target, s.cfg.signer) + if err != nil { + return nil, err + } + return txBodyResponse(append(msgs, ackMsgs...)) +} + +// inboundTimeout refunds packets cbdc-node SENT that Besu never received. +// timeout_tx_ids are the ORIGINAL send transactions, which live on cbdc-node +// (the request's dst_chain) — so the packets are read back out of our own +// events, and what Besu contributes is only the absence proof. Proving at head +// is deliberate: ibc-go accepts the timeout only if the consensus timestamp at +// the proof height is past the packet's timeout, so a too-early head fails on +// delivery and the relayer simply retries later. +func (s *server) inboundTimeout(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + rpcCli, err := rpc.DialContext(ctx, s.cfg.besuRPC) + if err != nil { + return nil, fmt.Errorf("dial besu: %w", err) + } + defer rpcCli.Close() + eth := ethclient.NewClient(rpcCli) + chain := besu.New(rpcCli) + + // dst_client_id is the flipped pair's name for the cbdc-node side: it is + // both the client these packets were sent on (their source_client) and the + // QBFT client the updates advance. + dstClient := req.GetDstClientId() + if dstClient == "" { + dstClient = s.cfg.cbdcClient + } + + packets, err := s.packetsFromCosmosTxs(ctx, req.GetTimeoutTxIds(), dstClient) + if err != nil { + return nil, err + } + if len(packets) == 0 { + return nil, fmt.Errorf("no send_packet events for client %s in the given transactions", dstClient) + } + + msgs, target, err := s.updatesToHead(ctx, chain, eth, dstClient) + if err != nil { + return nil, err + } + touts, err := relaytx.TimeoutMsgs(ctx, chain, s.cfg.router, s.cdc, packets, target, s.cfg.signer) + if err != nil { + return nil, err + } + return txBodyResponse(append(msgs, touts...)) +} + +// updatesToHead builds the MsgUpdateClient chain that advances dstClient to +// Besu's current head, returning the height proofs must then be read at. +// +// Prove at the client's trusted height when the chain has not advanced past +// it: UpdateChain refuses target <= trusted, and no update is needed — the +// consensus state for that height is already on cbdc-node. +func (s *server) updatesToHead(ctx context.Context, chain prover.ChainReader, eth *ethclient.Client, dstClient string) ([]sdk.Msg, uint64, error) { + trusted, err := s.clientLatestHeight(ctx, dstClient) + if err != nil { + return nil, 0, err + } + head, err := eth.BlockNumber(ctx) + if err != nil { + return nil, 0, fmt.Errorf("besu head: %w", err) + } + if head <= trusted { + return nil, trusted, nil + } + updates, err := relaytx.UpdateMsgs(ctx, chain, s.cfg.router, dstClient, trusted, head, s.cfg.signer) + if err != nil { + return nil, 0, err + } + return updates, head, nil +} + +// txBodyResponse packs the update+packet message sequence into the response. +// The relayer parses this as cosmos.tx.v1beta1.TxBody, re-wraps the messages +// into its own TxBuilder and signs with its own key — which is why every +// message carries the relayer's address as signer. +func txBodyResponse(msgs []sdk.Msg) (*proofapipb.RelayByTxResponse, error) { + anys := make([]*codectypes.Any, 0, len(msgs)) + for _, m := range msgs { + a, err := codectypes.NewAnyWithValue(m) + if err != nil { + return nil, fmt.Errorf("packing %T: %w", m, err) + } + anys = append(anys, a) + } + bz, err := (&sdktx.TxBody{Messages: anys}).Marshal() + if err != nil { + return nil, fmt.Errorf("marshal TxBody: %w", err) + } + // address is ignored by the relayer's cosmos delivery path (verified: the + // parameter is discarded); empty keeps the contract honest. + return &proofapipb.RelayByTxResponse{Tx: bz, Address: ""}, nil +} + +// packetsFromReceipts decodes SendPacket events out of the given transactions, +// keeping those sent by the router on srcClient. Deduped by sequence so a hash +// listed twice cannot produce a double MsgRecvPacket. +func (s *server) packetsFromReceipts(ctx context.Context, eth *ethclient.Client, txIDs [][]byte, srcClient string) ([]channeltypesv2.Packet, error) { + sendPacketID := routerABI.Events["SendPacket"].ID + seen := map[uint64]bool{} + var out []channeltypesv2.Packet + for _, id := range txIDs { + receipt, err := eth.TransactionReceipt(ctx, common.BytesToHash(id)) + if err != nil { + return nil, fmt.Errorf("receipt %x: %w", id, err) + } + for _, lg := range receipt.Logs { + if lg.Address != s.cfg.router || len(lg.Topics) == 0 || lg.Topics[0] != sendPacketID { + continue + } + pk, err := fromSendPacketLog(lg.Data) + if err != nil { + return nil, fmt.Errorf("tx %x: %w", id, err) + } + if pk.SourceClient != srcClient || seen[pk.Sequence] { + continue + } + seen[pk.Sequence] = true + out = append(out, pk) + } + } + return out, nil +} + +// acksFromReceipts decodes WriteAcknowledgement events out of the given +// transactions, keeping those the router wrote for packets received on +// srcClient — the packet's DESTINATION, which is the id the event (and the ack +// store key) carries. Returned acks pair with packets by index and are the RAW +// app acks. Deduped by sequence like packetsFromReceipts. Single-payload rig: +// exactly one ack per packet, and a different count is an error rather than a +// skip, because skipping would silently strand that packet's escrow forever. +func (s *server) acksFromReceipts(ctx context.Context, eth *ethclient.Client, txIDs [][]byte, srcClient string) ([]channeltypesv2.Packet, [][]byte, error) { + writeAckID := routerABI.Events["WriteAcknowledgement"].ID + seen := map[uint64]bool{} + var packets []channeltypesv2.Packet + var acks [][]byte + for _, id := range txIDs { + receipt, err := eth.TransactionReceipt(ctx, common.BytesToHash(id)) + if err != nil { + return nil, nil, fmt.Errorf("receipt %x: %w", id, err) + } + for _, lg := range receipt.Logs { + if lg.Address != s.cfg.router || len(lg.Topics) == 0 || lg.Topics[0] != writeAckID { + continue + } + pk, ackList, err := fromWriteAckLog(lg.Data) + if err != nil { + return nil, nil, fmt.Errorf("tx %x: %w", id, err) + } + if pk.DestinationClient != srcClient || seen[pk.Sequence] { + continue + } + if len(ackList) != 1 { + return nil, nil, fmt.Errorf("tx %x: expected exactly 1 ack for seq %d (single-payload rig), got %d", id, pk.Sequence, len(ackList)) + } + seen[pk.Sequence] = true + packets = append(packets, pk) + acks = append(acks, ackList[0]) + } + } + return packets, acks, nil +} + +// clientLatestHeight reads the QBFT client state straight from the IBC store. +// The stored value is an Any, not a bare ClientState. +func (s *server) clientLatestHeight(ctx context.Context, clientID string) (uint64, error) { + res, err := s.cbdc.ABCIQuery(ctx, "/store/ibc/key", host.FullClientStateKey(clientID)) + if err != nil { + return 0, fmt.Errorf("query client state: %w", err) + } + if res.Response.Code != 0 || len(res.Response.Value) == 0 { + return 0, fmt.Errorf("no client state for %s (code %d: %s)", clientID, res.Response.Code, res.Response.Log) + } + var anyCS codectypes.Any + if err := anyCS.Unmarshal(res.Response.Value); err != nil { + return 0, fmt.Errorf("unmarshal client state any: %w", err) + } + var cs qbfttypes.ClientState + if err := cs.Unmarshal(anyCS.Value); err != nil { + return 0, fmt.Errorf("unmarshal client state: %w", err) + } + return cs.LatestHeight, nil +} diff --git a/cmd/qbftproofapi/main.go b/cmd/qbftproofapi/main.go new file mode 100644 index 0000000..9f379d1 --- /dev/null +++ b/cmd/qbftproofapi/main.go @@ -0,0 +1,273 @@ +// Command qbftproofapi is the proof-API shim that lets cosmos/ibc-relayer +// drive the cbdc-node <-> Besu corridor (DEC-18) without upstream's proof +// machinery knowing anything about QBFT or the attestation pilot. +// +// The relayer calls exactly one RPC — proofapi.ProofApiService/RelayByTx — and +// signs/broadcasts whatever comes back itself. So this process is a pure +// translator, in both proof directions and for all three message kinds (recv, +// ack, timeout — acks and timeouts travel AGAINST their packet, so each proof +// direction serves packets sent both ways): +// +// - proofs FROM Besu (recv of Besu-sent packets; ack/timeout of +// cbdc-node-sent ones): build the UNSIGNED cosmos TxBody carrying +// [MsgUpdateClient..., then MsgRecvPacket / MsgAcknowledgement / +// MsgTimeout...], proofs from x/qbftclient/prover (shared with +// cmd/qbftrelay via relaytx). The signer field inside each message must be +// the RELAYER's bech32 address: upstream signs the tx with its own key and +// never rewrites message signers, and the SDK requires msg-signer == +// tx-signer. Hence -signer is an address, not a key. +// - attestations FROM cbdc-node (recv of cbdc-node-sent packets; ack/timeout +// of Besu-sent ones): build ICS26Router multicall calldata [updateClient, +// then recvPacket / ackPacket / timeoutPacket...], attestations fetched +// from the attestor sidecar — membership over the AggregatorService gRPC, +// receipt ABSENCE over the sidecar's HTTP /attest/absence, the only +// surface where non-membership intent is explicit. The sidecar verifies +// against its own cbdc-node view before signing, so nothing a caller sends +// here can smuggle in a commitment (or an absence). +// +// Like the rest of the corridor tooling this holds NO keys of any kind (DEC-7): +// not the attestor's, not the relayer's, not the chain's. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "os" + + "github.com/ethereum/go-ethereum/common" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + "github.com/peersyst/cbdc-node/app" + "github.com/peersyst/cbdc-node/x/qbftclient" + "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" + "github.com/peersyst/cbdc-node/x/qbftclient/proofapipb" + + "github.com/cosmos/cosmos-sdk/codec" +) + +type config struct { + listen string + besuRPC string + cbdcRPC string + attestorGRPC string + router common.Address + cbdcChainID string // relayer's chain_id string for cbdc-node + besuChainID string // relayer's chain_id string for Besu ("1337", decimal) + cbdcClient string // client id on cbdc-node tracking Besu + besuClient string // client id on Besu tracking cbdc-node + evmChainID uint64 // cbdc-node EVM chain id, for the tx encoding config + signer string // the RELAYER's bech32 address on cbdc-node + stateDir string // durable freeze-guard state (see guard.go) +} + +func main() { + var routerHex string + cfg := config{} + flag.StringVar(&cfg.listen, "listen", "127.0.0.1:8888", "gRPC listen address (relayer's ibcv2_proof_api.grpc_address)") + flag.StringVar(&cfg.besuRPC, "besu-rpc", "http://127.0.0.1:8645", "Besu JSON-RPC") + flag.StringVar(&cfg.cbdcRPC, "cbdc-rpc", "http://127.0.0.1:26657", "cbdc-node CometBFT RPC") + flag.StringVar(&cfg.attestorGRPC, "attestor-grpc", "127.0.0.1:8091", "attestor sidecar ibc_attestor.AttestationService") + flag.StringVar(&routerHex, "router", "", "ICS26Router address on Besu") + flag.StringVar(&cfg.cbdcChainID, "cbdc-chain-id", "cbdc-honduras_5040000-1", "cosmos chain id as configured in the relayer") + flag.StringVar(&cfg.besuChainID, "besu-chain-id", "1337", "Besu chain id as configured in the relayer (decimal)") + flag.StringVar(&cfg.cbdcClient, "cbdc-client", "qbftclient-0", "client id on cbdc-node") + flag.StringVar(&cfg.besuClient, "besu-client", "client-1", "client id on Besu") + flag.Uint64Var(&cfg.evmChainID, "evm-chain-id", 5040000, "cbdc-node EVM chain id for the tx encoding config") + flag.StringVar(&cfg.signer, "signer", "", "bech32 address the RELAYER signs with on cbdc-node") + // Required, not defaulted: the attestor this process drives is stateless, + // so these guards are the only thing standing between a re-genesis or a + // pruned query and an irreversible outcome. A shim that does not know where + // to keep that record must not start. + flag.StringVar(&cfg.stateDir, "state-dir", "", "durable guard state (required)") + flag.Parse() + + if routerHex == "" || cfg.signer == "" || cfg.stateDir == "" { + fmt.Fprintln(os.Stderr, "required: -router -signer -state-dir") + flag.Usage() + os.Exit(2) + } + cfg.router = common.HexToAddress(routerHex) + + encCfg := app.MakeEncodingConfig(cfg.evmChainID) + // MakeEncodingConfig wires the EVM interfaces only; the IBC v2 messages and + // the QBFT client types both have to be registered before Any-packing works. + channeltypesv2.RegisterInterfaces(encCfg.InterfaceRegistry) + clienttypes.RegisterInterfaces(encCfg.InterfaceRegistry) + qbftclient.RegisterInterfaces(encCfg.InterfaceRegistry) + + cbdc, err := rpchttp.New(cfg.cbdcRPC, "/websocket") + if err != nil { + log.Fatalf("cbdc rpc: %v", err) + } + + attConn, err := grpc.NewClient(cfg.attestorGRPC, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatalf("attestor grpc: %v", err) + } + defer attConn.Close() + + s := &server{ + cfg: cfg, + cdc: encCfg.Codec, + cbdc: cbdc, + attestor: attestorpb.NewAttestationServiceClient(attConn), + } + + // Bind to one chain INSTANCE before anything can be attested. The chain-id + // check catches a node pointed at the wrong network; block 1's hash catches + // a re-genesis that kept the id, which the first check cannot see. + status, err := cbdc.Status(context.Background()) + if err != nil { + //nolint:gocritic // exiting main; the OS reclaims what the defer would have released + log.Fatalf("cannot reach cbdc-node at %s: %v", cfg.cbdcRPC, err) + } + if got := status.NodeInfo.Network; got != cfg.cbdcChainID { + log.Fatalf("refusing to start: -cbdc-chain-id %q but the node reports %q", cfg.cbdcChainID, got) + } + if err := os.MkdirAll(cfg.stateDir, 0o700); err != nil { + log.Fatalf("state dir: %v", err) + } + b1, err := s.block1Hash(context.Background()) + if err != nil { + log.Fatalf("cannot read block 1 hash (needed for re-genesis detection): %v", err) + } + if err := checkGenesis(cfg.stateDir, cfg.cbdcChainID, b1); err != nil { + log.Fatalf("refusing to start: %v", err) + } + if s.guard, err = openGuardState(cfg.stateDir); err != nil { + log.Fatalf("guard state: %v", err) + } + + lis, err := net.Listen("tcp", cfg.listen) + if err != nil { + log.Fatalf("listen %s: %v", cfg.listen, err) + } + grpcSrv := grpc.NewServer() + proofapipb.RegisterProofApiServiceServer(grpcSrv, s) + // Reflection so grpcurl can poke the shim without vendored descriptors. + reflection.Register(grpcSrv) + log.Printf("qbftproofapi on %s", cfg.listen) + log.Printf(" %s -> %s : unsigned TxBody (recv/ack/timeout), proofs via x/qbftclient/prover, msgs signed by %s", cfg.besuChainID, cfg.cbdcChainID, cfg.signer) + log.Printf(" %s -> %s : ICS26Router multicall (recv/ack/timeout) via attestor at %s (AttestationService)", cfg.cbdcChainID, cfg.besuChainID, cfg.attestorGRPC) + log.Printf(" guard: %d attested height(s) known, state %s", len(s.guard.seen), cfg.stateDir) + if err := grpcSrv.Serve(lis); err != nil { + log.Fatalf("serve: %v", err) + } +} + +type server struct { + cfg config + cdc codec.Codec + cbdc *rpchttp.HTTP + attestor attestorpb.AttestationServiceClient + guard *guardState +} + +// RelayByTx is the one method cosmos/ibc-relayer invokes. Dispatch is on the +// exact chain_id strings the relayer was configured with, then on which +// fields are set. +func (s *server) RelayByTx(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + // The recv/ack/timeout shapes share one RPC, told apart by which fields are + // set (verified against upstream batch_recv_packet.go, batch_ack_packet.go + // and timeout_packet.go): + // + // recv: (src,dst) = (sender, receiver); source_tx_ids are the send + // transactions on src_chain, src_packet_sequences set. + // ack: (src,dst) FLIPPED — src_chain is the packet's DESTINATION, + // where the ack was written; source_tx_ids are the write-ack + // transactions on src_chain, dst_packet_sequences set and + // src_packet_sequences empty. + // timeout: same flipped pair, but timeout_tx_ids INSTEAD of + // source_tx_ids — and they are the ORIGINAL send transactions, + // which live on dst_chain, not src_chain. + // + // Acks and timeouts therefore always deliver on dst_chain, the chain that + // SENT the packet: that is where the commitment — and the escrow behind it + // — waits to be cleared or refunded. + timeout := len(req.GetTimeoutTxIds()) > 0 + ack := !timeout && len(req.GetDstPacketSequences()) > 0 && len(req.GetSrcPacketSequences()) == 0 + if !timeout && len(req.GetSourceTxIds()) == 0 { + return nil, status.Error(codes.InvalidArgument, "no source_tx_ids") + } + + switch { + case req.GetSrcChain() == s.cfg.besuChainID && req.GetDstChain() == s.cfg.cbdcChainID: + // Proofs out of Besu, unsigned TxBody to cbdc-node. + switch { + case timeout: + resp, err := s.inboundTimeout(ctx, req) + logOutcome("timeout ->"+s.cfg.cbdcChainID, req, err) + return resp, err + case ack: + resp, err := s.inboundAck(ctx, req) + logOutcome("ack ->"+s.cfg.cbdcChainID, req, err) + return resp, err + default: + resp, err := s.inbound(ctx, req) + logOutcome("recv "+s.cfg.besuChainID+"->"+s.cfg.cbdcChainID, req, err) + return resp, err + } + case req.GetSrcChain() == s.cfg.cbdcChainID && req.GetDstChain() == s.cfg.besuChainID: + // Attestations out of cbdc-node, ICS26Router calldata to Besu. + switch { + case timeout: + resp, err := s.outboundTimeout(ctx, req) + logOutcome("timeout ->"+s.cfg.besuChainID, req, err) + return resp, err + case ack: + resp, err := s.outboundAck(ctx, req) + logOutcome("ack ->"+s.cfg.besuChainID, req, err) + return resp, err + default: + resp, err := s.outbound(ctx, req) + logOutcome("recv "+s.cfg.cbdcChainID+"->"+s.cfg.besuChainID, req, err) + return resp, err + } + default: + return nil, status.Errorf(codes.NotFound, "unknown chain pair (%q, %q)", req.GetSrcChain(), req.GetDstChain()) + } +} + +func logOutcome(dir string, req *proofapipb.RelayByTxRequest, err error) { + // Recvs carry their sequences in src_packet_sequences, acks and timeouts in + // dst_packet_sequences; same for which tx-id field is filled. + seqs := req.GetSrcPacketSequences() + if len(seqs) == 0 { + seqs = req.GetDstPacketSequences() + } + txs := len(req.GetSourceTxIds()) + if txs == 0 { + txs = len(req.GetTimeoutTxIds()) + } + if err != nil { + log.Printf("%s seqs=%v: %v", dir, seqs, err) + return + } + log.Printf("%s seqs=%v: ok (%d txs)", dir, seqs, txs) +} + +// CreateClient, UpdateClient and Info exist in upstream's proto but are never +// invoked by the relayer at runtime; verified against its source. +func (s *server) CreateClient(context.Context, *proofapipb.CreateClientRequest) (*proofapipb.CreateClientResponse, error) { + return nil, status.Error(codes.Unimplemented, "CreateClient is not implemented") +} + +func (s *server) UpdateClient(context.Context, *proofapipb.UpdateClientRequest) (*proofapipb.UpdateClientResponse, error) { + return nil, status.Error(codes.Unimplemented, "UpdateClient is not implemented") +} + +func (s *server) Info(context.Context, *proofapipb.InfoRequest) (*proofapipb.InfoResponse, error) { + return nil, status.Error(codes.Unimplemented, "Info is not implemented") +} diff --git a/cmd/qbftproofapi/outbound.go b/cmd/qbftproofapi/outbound.go new file mode 100644 index 0000000..0c5775c --- /dev/null +++ b/cmd/qbftproofapi/outbound.go @@ -0,0 +1,433 @@ +package main + +// Outbound: everything attested OUT OF cbdc-node and delivered TO Besu as +// ICS26Router multicall calldata. Three kinds again: receives of +// cbdc-node-sent packets, plus acks and timeouts of Besu-sent packets (the +// flipped pair). No proof to build — the attestor sidecar attests (state + +// packet membership, or receipt absence) and the AttestationLightClient checks +// signatures. This shim only asks, wraps, and ABI-encodes. + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "log" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" + "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" + "github.com/peersyst/cbdc-node/x/qbftclient/proofapipb" +) + +func (s *server) outbound(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + srcClient := req.GetSrcClientId() + if srcClient == "" { + srcClient = s.cfg.cbdcClient + } + dstClient := req.GetDstClientId() + if dstClient == "" { + dstClient = s.cfg.besuClient + } + + packets, err := s.packetsFromCosmosTxs(ctx, req.GetSourceTxIds(), srcClient) + if err != nil { + return nil, err + } + if len(packets) == 0 { + return nil, fmt.Errorf("no send_packet events for client %s in the given transactions", srcClient) + } + + st, err := s.cbdc.Status(ctx) + if err != nil { + return nil, fmt.Errorf("cbdc status: %w", err) + } + height, err := uint64Height(st.SyncInfo.LatestBlockHeight) + if err != nil { + return nil, err + } + + // One attestation covers every packet in the batch: the sidecar derives each + // path from the packet, reads the commitment from its own cbdc-node at + // `height`, and refuses anything it cannot verify -- so a bogus sequence + // fails here, not on-chain. + stateProof, err := s.attestState(ctx, height) + if err != nil { + return nil, err + } + packetProof, err := s.attestPackets(ctx, height, packets, attestor.CommitmentKindPacket) + if err != nil { + return nil, err + } + + calldata, err := multicallRecv(dstClient, stateProof, packets, packetProof, height) + if err != nil { + return nil, err + } + + // The relayer sends this calldata as-is to `address`, which its EVM path + // requires to be a deployed contract — the router, never the light client: + // updateClient must route through the client registry. + return &proofapipb.RelayByTxResponse{Tx: calldata, Address: s.cfg.router.Hex()}, nil +} + +// outboundAck returns acknowledgements for packets Besu SENT: cbdc-node wrote +// the ack when it received, and the send commitment on Besu — the entry +// holding the escrow — stays set until ackPacket sees a membership proof of +// that ack. Same attestation trust path as outbound recv, just over the ack +// path (kind 3) instead of the commitment path; the sidecar reads the value +// from its own node either way, so nothing sent here can smuggle one in. +func (s *server) outboundAck(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + // The flipped pair puts the cbdc-node side client — the packets' + // DESTINATION — in src_client_id, and the Besu client to update in + // dst_client_id. + srcClient := req.GetSrcClientId() + if srcClient == "" { + srcClient = s.cfg.cbdcClient + } + dstClient := req.GetDstClientId() + if dstClient == "" { + dstClient = s.cfg.besuClient + } + + packets, acks, err := s.acksFromCosmosTxs(ctx, req.GetSourceTxIds(), srcClient) + if err != nil { + return nil, err + } + if len(packets) == 0 { + return nil, fmt.Errorf("no write_acknowledgement events for client %s in the given transactions", srcClient) + } + + st, err := s.cbdc.Status(ctx) + if err != nil { + return nil, fmt.Errorf("cbdc status: %w", err) + } + height, err := uint64Height(st.SyncInfo.LatestBlockHeight) + if err != nil { + return nil, err + } + + // CommitmentKindAck keys the path by the packet's DESTINATION client -- the + // receiver wrote the ack -- the mirror of the commitment keying in outbound + // recv. The sidecar applies that rule itself; this side only names the kind. + stateProof, err := s.attestState(ctx, height) + if err != nil { + return nil, err + } + packetProof, err := s.attestPackets(ctx, height, packets, attestor.CommitmentKindAck) + if err != nil { + return nil, err + } + + calldata, err := multicallAck(dstClient, stateProof, packets, acks, packetProof, height) + if err != nil { + return nil, err + } + return &proofapipb.RelayByTxResponse{Tx: calldata, Address: s.cfg.router.Hex()}, nil +} + +// outboundTimeout refunds packets Besu SENT that cbdc-node never received. +// timeout_tx_ids are the ORIGINAL send transactions, which live on Besu (the +// request's dst_chain), so the packets come out of Besu receipts and what +// cbdc-node contributes is only the absence attestation. The router refunds +// only if the trusted timestamp at the attested height is past the packet's +// timeout, so a too-early attestation reverts on delivery and the relayer +// retries — this shim never has to judge "timed out" itself. +func (s *server) outboundTimeout(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + rpcCli, err := rpc.DialContext(ctx, s.cfg.besuRPC) + if err != nil { + return nil, fmt.Errorf("dial besu: %w", err) + } + defer rpcCli.Close() + eth := ethclient.NewClient(rpcCli) + + // dst_client_id is the flipped pair's name for the Besu side: it is both + // the client these packets were sent on (their source_client) and the + // attestation client the updateClient call advances. + dstClient := req.GetDstClientId() + if dstClient == "" { + dstClient = s.cfg.besuClient + } + + packets, err := s.packetsFromReceipts(ctx, eth, req.GetTimeoutTxIds(), dstClient) + if err != nil { + return nil, err + } + if len(packets) == 0 { + return nil, fmt.Errorf("no SendPacket events for client %s in the given transactions", dstClient) + } + // A packet destined elsewhere would have its receipt path keyed by another + // client, giving a signature over a path hash the router never checks — a + // proof that verifies against nothing. Refuse loudly here instead of letting + // the relayer retry a permanent mismatch. + for _, pk := range packets { + if pk.DestinationClient != s.cfg.cbdcClient { + return nil, fmt.Errorf("packet %d is destined for client %s, not %s — the attestor cannot attest its receipt absence", pk.Sequence, pk.DestinationClient, s.cfg.cbdcClient) + } + } + + st, err := s.cbdc.Status(ctx) + if err != nil { + return nil, fmt.Errorf("cbdc status: %w", err) + } + height, err := uint64Height(st.SyncInfo.LatestBlockHeight) + if err != nil { + return nil, err + } + + // Non-membership travels the SAME call as membership now, distinguished by + // CommitmentKindReceipt rather than by a side-channel. The sidecar refuses + // if a receipt actually exists, so an already-delivered packet cannot be + // refunded here. + stateProof, err := s.attestState(ctx, height) + if err != nil { + return nil, err + } + absenceProof, err := s.attestPackets(ctx, height, packets, attestor.CommitmentKindReceipt) + if err != nil { + return nil, err + } + + calldata, err := multicallTimeout(dstClient, stateProof, packets, absenceProof, height) + if err != nil { + return nil, err + } + return &proofapipb.RelayByTxResponse{Tx: calldata, Address: s.cfg.router.Hex()}, nil +} + +// attestState asks the sidecar to sign (height, timestamp) and returns the +// abi.encode(AttestationProof) blob updateClient carries. +// +// The sidecar returns ONE signature because one attestor produces one; the +// contract's threshold check takes a list. Wrapping it as a one-element list is +// correct at 1-of-1 and is exactly the seam an aggregator fills at m-of-n, +// where the same field arrives already carrying several. +func (s *server) attestState(ctx context.Context, height uint64) ([]byte, error) { + // Read the timestamp ourselves and record it durably BEFORE asking. The + // attestor is stateless and would happily sign a second, different timestamp + // for this height, which freezes the light client permanently. See guard.go. + ts, err := s.blockTimeSeconds(ctx, height) + if err != nil { + return nil, fmt.Errorf("cannot verify height %d: %w", height, err) + } + if err := s.guardHeight(height, ts); err != nil { + return nil, err + } + + resp, err := s.attestor.StateAttestation(ctx, &attestorpb.StateAttestationRequest{Height: height}) + if err != nil { + return nil, fmt.Errorf("attestor state: %w", err) + } + att := resp.GetAttestation() + if att == nil || len(att.GetSignature()) == 0 { + return nil, fmt.Errorf("attestor returned no state attestation for height %d", height) + } + + // Check what was actually SIGNED, not the response's timestamp field, which + // no signature covers. A mismatch means the attestor and this process are + // reading different chain state; the signature already exists and cannot be + // recalled, so refusing to use it is all that is left -- loudly. + want, err := attestor.EncodeState(height, ts) + if err != nil { + return nil, err + } + if !bytes.Equal(att.GetAttestedData(), want) { + log.Printf("!!! ALARM: attestor signed a payload for height %d that does not match this node's view", height) + log.Printf("!!! expected (height=%d timestamp=%d); attestor signed %x", height, ts, att.GetAttestedData()) + log.Printf("!!! stop the corridor and investigate before relaying anything") + return nil, fmt.Errorf("REFUSING an attestation for height %d: signed payload does not match this node's state", height) + } + + proof, err := attestor.EncodeProof(att.GetAttestedData(), [][]byte{att.GetSignature()}) + if err != nil { + return nil, fmt.Errorf("encode state proof: %w", err) + } + return proof, nil +} + +// attestPackets asks the sidecar to sign a claim of the given kind about the +// given packets, and returns the abi.encode(AttestationProof) blob. +// +// The packets go over the wire as ABI-encoded Packet structs, not as paths: the +// sidecar derives the ICS-24 path from the packet and the kind, so this side +// never chooses which key is read. For CommitmentKindReceipt the claim is +// NON-membership, and the sidecar refuses it outright if a receipt is present. +func (s *server) attestPackets(ctx context.Context, height uint64, packets []channeltypesv2.Packet, kind attestor.CommitmentKind) ([]byte, error) { + encoded := make([][]byte, 0, len(packets)) + for _, pk := range packets { + sol := toAttestorPacket(pk) + + // 🔴 A receipt-absence attestation is authority to RELEASE ESCROW, so + // absence is re-established here from the store with a proof rather than + // trusting the attestor's app-level query -- which reports a pruned or + // unanswerable version as "not received". See provenAbsent in guard.go. + if kind == attestor.CommitmentKindReceipt { + path, err := attestor.PathForCommitmentType(sol, kind) + if err != nil { + return nil, err + } + if err := s.provenAbsent(ctx, path, height); err != nil { + return nil, fmt.Errorf("REFUSING a refund for seq %d at height %d: %w", pk.Sequence, height, err) + } + } + + bz, err := attestor.EncodePacket(sol) + if err != nil { + return nil, fmt.Errorf("encode packet %d: %w", pk.Sequence, err) + } + encoded = append(encoded, bz) + } + + resp, err := s.attestor.PacketAttestation(ctx, &attestorpb.PacketAttestationRequest{ + Height: height, + Packets: encoded, + CommitmentType: attestorpb.CommitmentType(kind), + }) + if err != nil { + // The sidecar's refusal says WHY -- a value exists where absence was + // claimed, the height is unproven -- which is the difference between a + // diagnosable corridor and a silent retry loop. + return nil, fmt.Errorf("attestor packets (kind %d): %w", kind, err) + } + att := resp.GetAttestation() + if att == nil || len(att.GetSignature()) == 0 { + return nil, fmt.Errorf("attestor returned no packet attestation for height %d", height) + } + proof, err := attestor.EncodeProof(att.GetAttestedData(), [][]byte{att.GetSignature()}) + if err != nil { + return nil, fmt.Errorf("encode packet proof: %w", err) + } + return proof, nil +} + +// toAttestorPacket converts the protobuf packet into the shared ABI shape. It +// mirrors toSolPacket in evm.go; the two exist separately because that one +// feeds go-ethereum's router ABI and this one feeds the attestor wire format, +// and coupling them would tie the attestor protocol to the router's calldata. +func toAttestorPacket(pk channeltypesv2.Packet) attestor.SolPacket { + out := attestor.SolPacket{ + Sequence: pk.Sequence, + SourceClient: pk.SourceClient, + DestClient: pk.DestinationClient, + TimeoutTimestamp: pk.TimeoutTimestamp, + } + for _, pl := range pk.Payloads { + out.Payloads = append(out.Payloads, attestor.SolPayload{ + SourcePort: pl.SourcePort, + DestPort: pl.DestinationPort, + Version: pl.Version, + Encoding: pl.Encoding, + Value: pl.Value, + }) + } + return out +} + +// packetsFromCosmosTxs extracts the packets the given cbdc-node transactions +// sent on srcClient, from their send_packet events. Deduped by sequence. +func (s *server) packetsFromCosmosTxs(ctx context.Context, txIDs [][]byte, srcClient string) ([]channeltypesv2.Packet, error) { + seen := map[uint64]bool{} + var out []channeltypesv2.Packet + for _, id := range txIDs { + res, err := s.cbdc.Tx(ctx, id, false) + if err != nil { + return nil, fmt.Errorf("tx %X: %w", id, err) + } + for _, ev := range res.TxResult.Events { + if ev.Type != "send_packet" { + continue + } + var pktHex string + for _, a := range ev.Attributes { + if a.Key == "encoded_packet_hex" { + pktHex = a.Value + } + } + if pktHex == "" { + continue + } + bz, err := hex.DecodeString(pktHex) + if err != nil { + return nil, fmt.Errorf("tx %X: bad encoded_packet_hex: %w", id, err) + } + var pk channeltypesv2.Packet + if err := pk.Unmarshal(bz); err != nil { + return nil, fmt.Errorf("tx %X: unmarshal packet: %w", id, err) + } + if pk.SourceClient != srcClient || seen[pk.Sequence] { + continue + } + seen[pk.Sequence] = true + out = append(out, pk) + } + } + return out, nil +} + +// acksFromCosmosTxs extracts (packet, raw app ack) pairs from the given +// cbdc-node transactions' write_acknowledgement events, keeping packets +// received on destClient — the id the event carries for inbound packets. +// The event's ack attribute is the protobuf Acknowledgement WRAPPER; the +// router expects the raw app ack and recomputes the commitment from it, so +// unwrapping here is what makes verification pass rather than fail. Deduped +// by sequence; acks pair with packets by index. +func (s *server) acksFromCosmosTxs(ctx context.Context, txIDs [][]byte, destClient string) ([]channeltypesv2.Packet, [][]byte, error) { + seen := map[uint64]bool{} + var packets []channeltypesv2.Packet + var acks [][]byte + for _, id := range txIDs { + res, err := s.cbdc.Tx(ctx, id, false) + if err != nil { + return nil, nil, fmt.Errorf("tx %X: %w", id, err) + } + for _, ev := range res.TxResult.Events { + if ev.Type != "write_acknowledgement" { + continue + } + var pktHex, ackHex string + for _, a := range ev.Attributes { + switch a.Key { + case "encoded_packet_hex": + pktHex = a.Value + case "encoded_acknowledgement_hex": + ackHex = a.Value + } + } + if pktHex == "" || ackHex == "" { + continue + } + bz, err := hex.DecodeString(pktHex) + if err != nil { + return nil, nil, fmt.Errorf("tx %X: bad encoded_packet_hex: %w", id, err) + } + var pk channeltypesv2.Packet + if err := pk.Unmarshal(bz); err != nil { + return nil, nil, fmt.Errorf("tx %X: unmarshal packet: %w", id, err) + } + if pk.DestinationClient != destClient || seen[pk.Sequence] { + continue + } + ackBz, err := hex.DecodeString(ackHex) + if err != nil { + return nil, nil, fmt.Errorf("tx %X: bad encoded_acknowledgement_hex: %w", id, err) + } + var ack channeltypesv2.Acknowledgement + if err := ack.Unmarshal(ackBz); err != nil { + return nil, nil, fmt.Errorf("tx %X: unmarshal acknowledgement: %w", id, err) + } + // An unexpected count is an error, not a skip: skipping would + // silently strand that packet's escrow on Besu forever. + if len(ack.AppAcknowledgements) != 1 { + return nil, nil, fmt.Errorf("tx %X: expected exactly 1 app ack for seq %d (single-payload rig), got %d", id, pk.Sequence, len(ack.AppAcknowledgements)) + } + seen[pk.Sequence] = true + packets = append(packets, pk) + acks = append(acks, ack.AppAcknowledgements[0]) + } + } + return packets, acks, nil +} diff --git a/cmd/qbftrelay/main.go b/cmd/qbftrelay/main.go new file mode 100644 index 0000000..52c792f --- /dev/null +++ b/cmd/qbftrelay/main.go @@ -0,0 +1,204 @@ +// Command qbftrelay builds the transaction that delivers a packet from a +// Besu/QBFT chain to cbdc-node. +// +// It does three things in one shot: read the counterparty chain, assemble the +// client updates needed to reach the height the packet was committed at, and prove +// the commitment out of the IBC contract's storage. The result is an *unsigned* +// transaction, signed and broadcast by the chain's own tooling. +// +// That split is deliberate (DEC-7). This tool constructs proofs; it does not sign, +// hold keys, watch events, retry, or keep state. Whoever operates the corridor runs +// it — this is the capability, not the service. +// +// Usage: +// +// qbftrelay --besu-rpc http://127.0.0.1:8645 --client-id qbft-0 \ +// --contract 0x... --trusted-height 100 \ +// --packet-hex --signer --out unsigned.json +// +// Then sign and broadcast with hnld: +// +// hnld tx sign unsigned.json --from alice ... --output-document signed.json +// hnld tx broadcast signed.json ... +package main + +import ( + "context" + "encoding/hex" + "flag" + "fmt" + "os" + + "github.com/ethereum/go-ethereum/common" + + sdk "github.com/cosmos/cosmos-sdk/types" + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + + "github.com/peersyst/cbdc-node/app" + "github.com/peersyst/cbdc-node/x/qbftclient" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/relaytx" +) + +func main() { + var ( + besuRPC = flag.String("besu-rpc", "http://127.0.0.1:8645", "counterparty Besu JSON-RPC endpoint") + clientID = flag.String("client-id", "", "QBFT client id on cbdc-node to update") + contract = flag.String("contract", "", "IBC contract address on the counterparty") + trustedAt = flag.Uint64("trusted-height", 0, "height the QBFT client has already verified") + targetAt = flag.Uint64("target-height", 0, "height to prove the packet at; 0 means the packet's own height is unknown, so this is required") + packetHex = flag.String("packet-hex", "", "encoded packet from the counterparty's send event") + timeoutMode = flag.Bool("as-timeout", false, "build a MsgTimeout instead of a MsgRecvPacket. NB: not named -timeout, which the testing package already registers as a duration in any binary that links it: proves the packet receipt is ABSENT on the counterparty, which refunds the escrow on this chain") + ackMode = flag.Bool("as-ack", false, "build a MsgAcknowledgement instead of a MsgRecvPacket: proves the counterparty wrote an ack for a packet THIS chain sent, which clears the commitment here (and refunds on an error ack)") + ackHex = flag.String("ack-hex", "", "raw application acknowledgement bytes (hex), i.e. one element of the counterparty WriteAcknowledgement event's acknowledgements array -- NOT the protobuf Acknowledgement wrapper; required with -as-ack") + signer = flag.String("signer", "", "bech32 address that will sign on cbdc-node") + evmChain = flag.Uint64("evm-chain-id", 1449999, "cbdc-node EVM chain id, for the tx encoding config") + gasLimit = flag.Uint64("gas", 2_000_000, "gas limit for the generated tx") + out = flag.String("out", "unsigned.json", "file to write the unsigned tx to") + ) + flag.Parse() + + if *clientID == "" || *contract == "" || *packetHex == "" || *signer == "" || *trustedAt == 0 || *targetAt == 0 { + fmt.Fprintln(os.Stderr, "client-id, contract, trusted-height, target-height, packet-hex and signer are all required") + flag.Usage() + os.Exit(2) + } + if *timeoutMode && *ackMode { + fmt.Fprintln(os.Stderr, "-as-timeout and -as-ack are mutually exclusive: a packet is either refunded or acknowledged, never both") + os.Exit(2) + } + if *ackMode && *ackHex == "" { + fmt.Fprintln(os.Stderr, "-as-ack requires -ack-hex (the raw app acknowledgement from the counterparty's WriteAcknowledgement event)") + os.Exit(2) + } + + cfg := config{ + besuRPC: *besuRPC, + clientID: *clientID, + contract: common.HexToAddress(*contract), + trusted: *trustedAt, + target: *targetAt, + signer: *signer, + evmChain: *evmChain, + gasLimit: *gasLimit, + out: *out, + timeout: *timeoutMode, + ack: *ackMode, + ackHex: *ackHex, + } + + if err := run(context.Background(), cfg, *packetHex); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +type config struct { + besuRPC string + clientID string + contract common.Address + trusted uint64 + target uint64 + signer string + evmChain uint64 + gasLimit uint64 + out string + timeout bool + ack bool + ackHex string +} + +func run(ctx context.Context, cfg config, packetHex string) error { + encCfg := app.MakeEncodingConfig(cfg.evmChain) + // MakeEncodingConfig wires the EVM interfaces only, so the IBC v2 messages and + // the QBFT client types both have to be registered before the tx can be packed. + channeltypesv2.RegisterInterfaces(encCfg.InterfaceRegistry) + clienttypes.RegisterInterfaces(encCfg.InterfaceRegistry) + qbftclient.RegisterInterfaces(encCfg.InterfaceRegistry) + + packetBz, err := hex.DecodeString(packetHex) + if err != nil { + return fmt.Errorf("decode packet hex: %w", err) + } + var packet channeltypesv2.Packet + if err := packet.Unmarshal(packetBz); err != nil { + return fmt.Errorf("unmarshal packet: %w", err) + } + + chain, err := besu.Dial(ctx, cfg.besuRPC) + if err != nil { + return err + } + defer chain.Close() + + // Shared with cmd/qbftproofapi via relaytx, so the CLI and the service + // cannot drift apart on how a client update is assembled. + updates, err := relaytx.UpdateMsgs(ctx, chain, cfg.contract, cfg.clientID, cfg.trusted, cfg.target, cfg.signer) + if err != nil { + return err + } + + // Three message kinds, three proofs — receive (commitment PRESENT), timeout + // (receipt ABSENT), ack (acknowledgement PRESENT) — all built via relaytx so + // this CLI and cmd/qbftproofapi cannot drift apart on the proof keying + // either; the details of which store each proof reads live there. + var final sdk.Msg + switch { + case cfg.timeout: + touts, err := relaytx.TimeoutMsgs(ctx, chain, cfg.contract, encCfg.Codec, + []channeltypesv2.Packet{packet}, cfg.target, cfg.signer) + if err != nil { + return err + } + final = touts[0] + case cfg.ack: + ackBz, decErr := hex.DecodeString(cfg.ackHex) + if decErr != nil { + return fmt.Errorf("decode ack hex: %w", decErr) + } + acks, err := relaytx.AckMsgs(ctx, chain, cfg.contract, encCfg.Codec, + []channeltypesv2.Packet{packet}, [][]byte{ackBz}, cfg.target, cfg.signer) + if err != nil { + return err + } + final = acks[0] + default: + recvs, err := relaytx.RecvMsgs(ctx, chain, cfg.contract, encCfg.Codec, + []channeltypesv2.Packet{packet}, cfg.target, cfg.signer) + if err != nil { + return err + } + final = recvs[0] + } + recv := final + + // The updates must precede the receive in the same transaction: the proof is + // verified against the consensus state the last update writes. + all := append(append([]sdk.Msg{}, updates...), recv) + + txBuilder := encCfg.TxConfig.NewTxBuilder() + if err := txBuilder.SetMsgs(all...); err != nil { + return fmt.Errorf("set msgs: %w", err) + } + txBuilder.SetGasLimit(cfg.gasLimit) + + bz, err := encCfg.TxConfig.TxJSONEncoder()(txBuilder.GetTx()) + if err != nil { + return fmt.Errorf("encode tx: %w", err) + } + if err := os.WriteFile(cfg.out, bz, 0o600); err != nil { + return fmt.Errorf("write %s: %w", cfg.out, err) + } + + fmt.Printf("packet %s -> %s seq %d\n", packet.SourceClient, packet.DestinationClient, packet.Sequence) + fmt.Printf("client %s, %d -> %d\n", cfg.clientID, cfg.trusted, cfg.target) + if len(updates) > 1 { + fmt.Printf("updates %d headers (the validator set changed in this range)\n", len(updates)) + } else { + fmt.Printf("updates 1 header\n") + } + fmt.Printf("wrote unsigned tx to %s\n", cfg.out) + return nil +} diff --git a/cmd/sp1fixture/main.go b/cmd/sp1fixture/main.go new file mode 100644 index 0000000..85da781 --- /dev/null +++ b/cmd/sp1fixture/main.go @@ -0,0 +1,205 @@ +// Command sp1fixture builds an SP1 ICS07 update-client fixture from a live +// cbdc-node, in the exact shape solidity-ibc-eureka's own Rust tests consume: +// +// { client_state_hex, consensus_state_hex, update_client_message: { client_message_hex } } +// +// Answers SP1 spike Stage 1 (docs/ibc-v2-sp1-spike.md Q1): whether the stock +// guest program accepts cbdc-node's headers with no modification. Throwaway +// tool for that measurement. +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "time" + + cmthttp "github.com/cometbft/cometbft/rpc/client/http" + cmttypes "github.com/cometbft/cometbft/types" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + commitmenttypes "github.com/cosmos/ibc-go/v10/modules/core/23-commitment/types" + ibctm "github.com/cosmos/ibc-go/v10/modules/light-clients/07-tendermint" +) + +func main() { + rpc := flag.String("rpc", "tcp://127.0.0.1:26757", "cbdc-node CometBFT RPC") + trusted := flag.Int64("trusted", 0, "trusted height (0 = latest-2)") + target := flag.Int64("target", 0, "target height (0 = latest)") + out := flag.String("out", "fixture.json", "output fixture path") + flag.Parse() + + if err := run(*rpc, *trusted, *target, *out); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(rpcAddr string, trustedH, targetH int64, outPath string) error { + ctx := context.Background() + c, err := cmthttp.New(rpcAddr, "/websocket") + if err != nil { + return fmt.Errorf("rpc client: %w", err) + } + + status, err := c.Status(ctx) + if err != nil { + return fmt.Errorf("status: %w", err) + } + latest := status.SyncInfo.LatestBlockHeight + chainID := status.NodeInfo.Network + if targetH == 0 { + targetH = latest + } + if trustedH == 0 { + trustedH = targetH - 2 + } + if trustedH < 1 { + return fmt.Errorf("need at least 3 blocks; latest=%d", latest) + } + fmt.Printf("chain-id=%s latest=%d trusted=%d target=%d\n", chainID, latest, trustedH, targetH) + + // The proposed header: signed header + the validator set that signed it. + targetCommit, err := c.Commit(ctx, &targetH) + if err != nil { + return fmt.Errorf("commit(%d): %w", targetH, err) + } + targetVals, err := valSet(ctx, c, targetH, targetCommit.Header.ProposerAddress) + if err != nil { + return fmt.Errorf("validators(%d): %w", targetH, err) + } + + // TrustedValidators must hash to the trusted consensus state's + // NextValidatorsHash, which is the set for trustedHeight+1 -- not + // trustedHeight. Getting this wrong is a silent verification failure. + trustedNext := trustedH + 1 + trustedCommit, err := c.Commit(ctx, &trustedH) + if err != nil { + return fmt.Errorf("commit(%d): %w", trustedH, err) + } + trustedVals, err := valSet(ctx, c, trustedNext, trustedCommit.Header.ProposerAddress) + if err != nil { + return fmt.Errorf("validators(%d): %w", trustedNext, err) + } + + revision := clienttypes.ParseChainID(chainID) + fmt.Printf("revision number parsed from chain-id: %d\n", revision) + + targetValsProto, err := targetVals.ToProto() + if err != nil { + return fmt.Errorf("target valset proto: %w", err) + } + trustedValsProto, err := trustedVals.ToProto() + if err != nil { + return fmt.Errorf("trusted valset proto: %w", err) + } + + header := &ibctm.Header{ + SignedHeader: targetCommit.SignedHeader.ToProto(), + ValidatorSet: targetValsProto, + //nolint:gosec // height read from a live node moments earlier + TrustedHeight: clienttypes.NewHeight(revision, uint64(trustedH)), + TrustedValidators: trustedValsProto, + } + + // DEC-8's periods. The 14-day trusting period is only expressible because + // the chain's unbonding_time is 21 days. + clientState := ibctm.NewClientState( + chainID, + ibctm.DefaultTrustLevel, // 1/3 + 14*24*time.Hour, // trusting period -- DEC-8 + 21*24*time.Hour, // unbonding period -- DEC-8 + 10*time.Second, // max clock drift + //nolint:gosec // height read from a live node moments earlier + clienttypes.NewHeight(revision, uint64(targetH)), + commitmenttypes.GetSDKSpecs(), + []string{"upgrade", "upgradedIBCState"}, + ) + + consensusState := ibctm.NewConsensusState( + trustedCommit.Header.Time, + commitmenttypes.NewMerkleRoot(trustedCommit.Header.AppHash), + trustedCommit.Header.NextValidatorsHash, + ) + + hdrBz, err := header.Marshal() + if err != nil { + return fmt.Errorf("marshal header: %w", err) + } + csBz, err := clientState.Marshal() + if err != nil { + return fmt.Errorf("marshal client state: %w", err) + } + consBz, err := consensusState.Marshal() + if err != nil { + return fmt.Errorf("marshal consensus state: %w", err) + } + + // Report what the fixture actually contains, so a failure downstream can be + // attributed to the data or to the verifier rather than guessed at. + fmt.Printf("validators at target: %d (total power %d)\n", + len(targetVals.Validators), targetVals.TotalVotingPower()) + for _, v := range targetVals.Validators { + fmt.Printf(" %s power=%d keytype=%s\n", v.Address, v.VotingPower, v.PubKey.Type()) + } + signed := 0 + for _, s := range targetCommit.Commit.Signatures { + if s.BlockIDFlag == cmttypes.BlockIDFlagCommit { + signed++ + } + } + fmt.Printf("commit signatures present: %d/%d\n", signed, len(targetCommit.Commit.Signatures)) + fmt.Printf("header bytes=%d client_state bytes=%d consensus_state bytes=%d\n", + len(hdrBz), len(csBz), len(consBz)) + + fixture := map[string]any{ + "client_state_hex": hex.EncodeToString(csBz), + "consensus_state_hex": hex.EncodeToString(consBz), + "update_client_message": map[string]any{ + "client_message_hex": hex.EncodeToString(hdrBz), + }, + "_meta": map[string]any{ + "chain_id": chainID, + "trusted_height": trustedH, + "target_height": targetH, + "revision_number": revision, + "validators": len(targetVals.Validators), + "total_power": targetVals.TotalVotingPower(), + }, + } + bz, err := json.MarshalIndent(fixture, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(outPath, bz, 0o600); err != nil { + return err + } + fmt.Printf("wrote %s\n", outPath) + return nil +} + +// valSet pages through the validators at h and returns them as a CometBFT +// ValidatorSet with the proposer set from the block header, which is what the +// proto form carries. +func valSet(ctx context.Context, c *cmthttp.HTTP, h int64, proposer cmttypes.Address) (*cmttypes.ValidatorSet, error) { + var all []*cmttypes.Validator + page, perPage := 1, 100 + for { + res, err := c.Validators(ctx, &h, &page, &perPage) + if err != nil { + return nil, err + } + all = append(all, res.Validators...) + if len(all) >= res.Total { + break + } + page++ + } + vs := cmttypes.NewValidatorSet(all) + if _, val := vs.GetByAddress(proposer); val != nil { + vs.Proposer = val + } + return vs, nil +} diff --git a/cmd/v2relay/main.go b/cmd/v2relay/main.go new file mode 100644 index 0000000..03d9069 --- /dev/null +++ b/cmd/v2relay/main.go @@ -0,0 +1,147 @@ +// Command v2relay builds an IBC v2 packet-receive transaction from a Cosmos +// source chain: it queries the packet commitment proof and emits an *unsigned* +// MsgRecvPacket, which is then signed with cbdc-node's own keyring. +// +// That split -- proof construction here, signing by the chain's own tooling -- +// is the same architecture the upstream Eureka proof-api uses, and it is the +// shape the QBFT proof constructor follows for the Besu source leg (DEC-7). +// +// Scope: Cosmos source only. Proofs are ABCI queries verified through ICS-23, +// so this tool cannot produce the Ethereum Merkle-Patricia proofs a Besu source +// leg needs -- see x/qbftclient/types for that half. +// +// Status: retained as a manual break-glass tool. It is one-shot -- no event +// loop, no ack or timeout legs, no retries. +// +// Two constraints cited by earlier versions of this comment were retired on +// 2026-07-27 and no longer apply: cosmos/ibc-relayer's production license bar +// (a commercial license was adopted) and its inability to sign eth_secp256k1 +// (the chain also accepts plain cosmos secp256k1 service accounts). +// +// Light clients are shared between IBC v1 and v2, so client creation and the +// client updates this proof is verified against can still be handled by Hermes. +// +// Usage: +// +// v2relay --src-rpc http://127.0.0.1:26657 --src-client 07-tendermint-0 \ +// --sequence 1 --packet-hex --signer --out unsigned.json +// +// Then sign and broadcast with hnld: +// +// hnld tx sign unsigned.json --from alice ... --output-document signed.json +// hnld tx broadcast signed.json ... +package main + +import ( + "context" + "encoding/hex" + "flag" + "fmt" + "os" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + "github.com/peersyst/cbdc-node/app" + cosmosprover "github.com/peersyst/cbdc-node/x/qbftclient/prover/cosmos" +) + +func main() { + var ( + srcRPC = flag.String("src-rpc", "http://127.0.0.1:26657", "source chain CometBFT RPC") + srcClient = flag.String("src-client", "", "source client id the packet was sent on") + sequence = flag.Uint64("sequence", 0, "packet sequence") + packetHex = flag.String("packet-hex", "", "encoded_packet_hex from the send_packet event") + signer = flag.String("signer", "", "bech32 address that will sign on the destination chain") + evmChain = flag.Uint64("evm-chain-id", 1449998, "destination EVM chain id (parsed from its cosmos chain id)") + gasLimit = flag.Uint64("gas", 1_500_000, "gas limit for the generated tx") + qHeight = flag.Int64("query-height", 0, "source height to prove against; 0 means latest-1. Must be below the destination client's latest height") + out = flag.String("out", "unsigned.json", "file to write the unsigned tx to") + ) + flag.Parse() + + if *srcClient == "" || *sequence == 0 || *packetHex == "" || *signer == "" { + fmt.Fprintln(os.Stderr, "src-client, sequence, packet-hex and signer are all required") + flag.Usage() + os.Exit(2) + } + + if err := run(*srcRPC, *srcClient, *sequence, *packetHex, *signer, *evmChain, *gasLimit, *qHeight, *out); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run(srcRPC, srcClient string, sequence uint64, packetHex, signer string, evmChainID, gasLimit uint64, qHeight int64, out string) error { + encCfg := app.MakeEncodingConfig(evmChainID) + // MakeEncodingConfig wires the EVM interfaces only, so the IBC v2 channel + // messages have to be registered before MsgRecvPacket can be packed into a tx. + channeltypesv2.RegisterInterfaces(encCfg.InterfaceRegistry) + + // The packet comes straight off the send_packet event, so it does not have + // to be reconstructed field by field. + packetBz, err := hex.DecodeString(packetHex) + if err != nil { + return fmt.Errorf("decode packet hex: %w", err) + } + var packet channeltypesv2.Packet + if err := packet.Unmarshal(packetBz); err != nil { + return fmt.Errorf("unmarshal v2 packet: %w", err) + } + + // The client and sequence are carried by the packet as well as by the flags. + // If they disagree the commitment lookup below uses the packet, so we would + // prove one packet and submit another -- rejected on chain, after the operator + // has already paid for the round trip. Fail here instead. + if packet.SourceClient != srcClient || packet.Sequence != sequence { + return fmt.Errorf("flags disagree with the packet: --src-client=%s --sequence=%d, but the packet is %s sequence %d", + srcClient, sequence, packet.SourceClient, packet.Sequence) + } + + cli, err := rpchttp.New(srcRPC, "/websocket") + if err != nil { + return fmt.Errorf("connect to source rpc: %w", err) + } + ctx := context.Background() + + status, err := cli.Status(ctx) + if err != nil { + return fmt.Errorf("query source status: %w", err) + } + + prover := cosmosprover.New(cli, encCfg.Codec, status.NodeInfo.Network) + + queryHeight := qHeight + if queryHeight == 0 { + if queryHeight, err = prover.SettledHeight(ctx); err != nil { + return err + } + } + + proven, err := prover.PacketCommitment(ctx, packet, queryHeight) + if err != nil { + return err + } + proof, proofHeight := proven.Proof, proven.Height + + msg := channeltypesv2.NewMsgRecvPacket(packet, proof, proofHeight, signer) + + txBuilder := encCfg.TxConfig.NewTxBuilder() + if err := txBuilder.SetMsgs(msg); err != nil { + return fmt.Errorf("set msgs: %w", err) + } + txBuilder.SetGasLimit(gasLimit) + + bz, err := encCfg.TxConfig.TxJSONEncoder()(txBuilder.GetTx()) + if err != nil { + return fmt.Errorf("encode tx: %w", err) + } + if err := os.WriteFile(out, bz, 0o600); err != nil { + return fmt.Errorf("write %s: %w", out, err) + } + + fmt.Printf("packet %s -> %s seq %d\n", packet.SourceClient, packet.DestinationClient, packet.Sequence) + fmt.Printf("proofHeight %s\n", proofHeight) + fmt.Printf("wrote unsigned MsgRecvPacket to %s\n", out) + return nil +} diff --git a/contracts/spoke/HondurasCBDC.sol b/contracts/spoke/HondurasCBDC.sol new file mode 100644 index 0000000..2dd5405 --- /dev/null +++ b/contracts/spoke/HondurasCBDC.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import { ERC20 } from "@openzeppelin-contracts/token/ERC20/ERC20.sol"; + +/// @notice Spoke-side representation of Honduras CBDC, pre-registered with +/// ICS20Transfer.setCustomERC20 BEFORE the first packet. +/// +/// WHY THIS CONTRACT EXISTS +/// +/// ICS-20 carries only the denom string, so name, symbol and decimals never +/// cross the boundary. Left alone, ICS20Transfer auto-deploys an IBCERC20 whose +/// name() returns the raw trace ("transfer/client-1/acbdc"). IBCERC20 at +/// solidity-v3.0.2 has custom-metadata storage but NO setter -- setMetadata was +/// removed in v3.0.0 -- so the name cannot be corrected after the fact. +/// +/// Pre-registering this token wins the same way genesis-seeded bank metadata +/// wins on the Cosmos side: _getOrCreateIBCERC20 consults the mapping first and +/// only auto-deploys when it is empty. The registration is therefore a +/// DEPLOY-ORDER requirement, not a fix-up: setCustomERC20 reverts once a denom +/// is mapped. +contract HondurasCBDC is ERC20 { + /// @notice The only address allowed to mint and burn: the ICS20Transfer proxy. + address public immutable ICS20; + + error OnlyICS20(); + + constructor(address ics20) ERC20("Honduras CBDC", "HNL") { + ICS20 = ics20; + } + + modifier onlyICS20() { + require(msg.sender == ICS20, OnlyICS20()); + _; + } + + /// @dev Mint target is the per-client Escrow, not the end recipient. + function mint(address mintAddress, uint256 amount) external onlyICS20 { + _mint(mintAddress, amount); + } + + function burn(address burnAddress, uint256 amount) external onlyICS20 { + _burn(burnAddress, amount); + } +} diff --git a/contracts/spoke/README.md b/contracts/spoke/README.md new file mode 100644 index 0000000..801c7d9 --- /dev/null +++ b/contracts/spoke/README.md @@ -0,0 +1,29 @@ +# Spoke-side contracts + +Solidity we author for the Besu spoke. These are **not** modifications of +`cosmos/solidity-ibc-eureka` — that repository is deploy-and-configure only, +and forking it would forfeit the upstream audit, which after DEC-28 is the +only external audit anywhere in this system. + +## `HondurasCBDC.sol` + +The spoke-side representation of a cbdc-node-native token, registered with +`ICS20Transfer.setCustomERC20(denom, token)` **before the first packet**. + +Why it has to exist: ICS-20 carries only the denom string, so name, symbol and +decimals never cross the boundary. Left alone, `ICS20Transfer` auto-deploys an +`IBCERC20` whose `name()` returns the raw trace — `transfer/client-1/acbdc` +rather than "Honduras CBDC". That cannot be corrected afterwards: +`IBCERC20.setMetadata` existed in v1.0.0–v2.0.0 and was **removed in v3.0.0** +with the AccessManager migration, and `main` is byte-identical to the tag. + +Pre-registration wins the same way genesis-seeded bank metadata wins on the +Cosmos side: `_getOrCreateIBCERC20` consults the mapping first and only +auto-deploys when it is empty. + +⚠️ **This is a deploy-order requirement, not a fix-up.** `setCustomERC20` +reverts once a denom is mapped, so the window closes at the first packet. + +⚠️ **It needs review in its own right.** `setCustomERC20` validates nothing +about the address it is handed, and this contract holds mint and burn +authority over spoke-side value. diff --git a/go.mod b/go.mod index ecb9a08..5da91a6 100644 --- a/go.mod +++ b/go.mod @@ -25,12 +25,12 @@ require ( github.com/cosmos/ibc-apps/modules/rate-limiting/v10 v10.1.0 github.com/cosmos/ibc-go/modules/capability v1.0.1 github.com/cosmos/ibc-go/v10 v10.3.1-0.20250909102629-ed3b125c7b6f + github.com/cosmos/ics23/go v0.11.0 github.com/ethereum/go-ethereum v1.15.11 github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.4 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/grpc-gateway v1.16.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 github.com/spf13/cast v1.10.0 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 @@ -92,7 +92,6 @@ require ( github.com/cosmos/go-bip39 v1.0.0 // indirect github.com/cosmos/gogogateway v1.2.0 // indirect github.com/cosmos/iavl v1.2.2 // indirect - github.com/cosmos/ics23/go v0.11.0 // indirect github.com/cosmos/ledger-cosmos-go v1.0.0 // indirect github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect @@ -119,7 +118,6 @@ require ( github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/getsentry/sentry-go v0.35.0 // indirect - github.com/ghodss/yaml v1.0.0 // indirect github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/go-kit/kit v0.13.0 // indirect github.com/go-kit/log v0.2.1 // indirect @@ -133,7 +131,6 @@ require ( github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/glog v1.2.5 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect github.com/google/btree v1.1.3 // indirect @@ -285,7 +282,7 @@ replace ( // use Cosmos-SDK fork to enable Ledger functionality github.com/cosmos/cosmos-sdk => github.com/xrplevm/cosmos-sdk v0.53.6-xrplevm.1 // cosmos evm private fork - github.com/cosmos/evm => github.com/xrplevm/evm v0.6.0-xrplevm.6 + github.com/cosmos/evm => github.com/xrplevm/evm v0.6.1-xrplevm.1 // fix cosmos-sdk store path mismatch // github.com/cosmos/cosmos-sdk/store => cosmossdk.io/store v1.1.2 github.com/ethereum/go-ethereum => github.com/cosmos/go-ethereum v0.0.0-20250806193535-2fc7571efa91 diff --git a/go.sum b/go.sum index f4a5e07..adc8741 100644 --- a/go.sum +++ b/go.sum @@ -761,12 +761,8 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= -github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= -github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= @@ -1001,7 +997,6 @@ github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqG github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.35.0 h1:+FJNlnjJsZMG3g0/rmmP7GiKjQoUF5EXfEtBwtPtkzY= github.com/getsentry/sentry-go v0.35.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= @@ -1078,8 +1073,6 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= -github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= -github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -1235,8 +1228,6 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= @@ -1731,8 +1722,8 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xrplevm/cosmos-sdk v0.53.6-xrplevm.1 h1:fBMklkMKZbrVoEhGU0JyeaINkRA9lVA9K/zRY73EGh0= github.com/xrplevm/cosmos-sdk v0.53.6-xrplevm.1/go.mod h1:N6YuprhAabInbT3YGumGDKONbvPX5dNro7RjHvkQoKE= -github.com/xrplevm/evm v0.6.0-xrplevm.6 h1:kgFyqrDwwJYfPyYstbCl2fqg70AP7/rw8oNEk+Gz6S8= -github.com/xrplevm/evm v0.6.0-xrplevm.6/go.mod h1:MUrVrODPlGdehAzc2KjUPEVHLtA7WChEvrFLs5kWa9E= +github.com/xrplevm/evm v0.6.1-xrplevm.1 h1:YrB+qiTo59Hh0SMzn6V6/z4v9lgbSSwWfAFVIjpHFeY= +github.com/xrplevm/evm v0.6.1-xrplevm.1/go.mod h1:QnaJDtxqon2mywiYqxM8VwW8FKeFazi0au0qzVpFAG8= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/local-node.sh b/local-node.sh index 6b0c867..45de42c 100755 --- a/local-node.sh +++ b/local-node.sh @@ -1,4 +1,11 @@ -CHAINID="cbdc_1449999-1" +#!/usr/bin/env bash +# The script is executable and uses bash-only syntax ([[ ]], $OSTYPE), so it must +# declare bash explicitly -- without this it runs under /bin/sh, which is dash on +# Debian-based images and fails on those constructs. + +# Overridable so the DEC-47 pilot network (cbdc-honduras_5040000-1) can be stood up +# without touching the devnet default, which DEC-23 leaves at cbdc_1449999-1. +CHAINID="${CHAINID:-cbdc_1449999-1}" MONIKER="localnet" # Remember to change to other types of keyring like 'file' in-case exposing to outside world, # otherwise your balance will be wiped quickly @@ -7,7 +14,7 @@ KEYRING="test" KEYALGO="eth_secp256k1" LOGLEVEL="info" # Set dedicated home directory for the evmosd instance -HOMEDIR="$PWD/.hnld" +HOMEDIR="${HOMEDIR:-$PWD/.hnld}" # to trace evm #TRACE="--trace" TRACE="" @@ -15,6 +22,11 @@ TRACE="" # feemarket params basefee BASEFEE=0 +# staking unbonding time. A tendermint light client's trusting period must be +# shorter than this, so at the 60s default an IBC client expires almost as soon +# as it is created. Override for IBC work, e.g. UNBONDING_TIME=1814400s. +UNBONDING_TIME="${UNBONDING_TIME:-60s}" + # Path variables CONFIG=$HOMEDIR/config/config.toml APP_TOML=$HOMEDIR/config/app.toml @@ -41,10 +53,17 @@ jq '.app_state["crisis"]["constant_fee"]["denom"]="axrp"' "$GENESIS" >"$TMP_GENE jq '.app_state["evm"]["params"]["evm_denom"]="axrp"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["gov"]["params"]["min_deposit"][0]["denom"]="axrp"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["gov"]["params"]["min_deposit"][0]["amount"]="1"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" +# expedited_min_deposit was left at the SDK default of 50000000"stake" -- a denom +# this chain does not have, so the expedited path could never be funded and was +# silently unusable. It must also be strictly greater than min_deposit +# (x/gov Params.ValidateBasic: minExpeditedDeposit.IsAllLTE(minDeposit) is an error), +# so 2 is the smallest consistent value. +jq '.app_state["gov"]["params"]["expedited_min_deposit"][0]["denom"]="axrp"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" +jq '.app_state["gov"]["params"]["expedited_min_deposit"][0]["amount"]="2"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["gov"]["params"]["voting_period"]="10s"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["gov"]["params"]["expedited_voting_period"]="5s"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["staking"]["params"]["bond_denom"]="apoa"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" -jq '.app_state["staking"]["params"]["unbonding_time"]="60s"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" +jq '.app_state["staking"]["params"]["unbonding_time"]="'${UNBONDING_TIME}'"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["feemarket"]["params"]["base_fee"]="'${BASEFEE}'"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["feemarket"]["params"]["no_base_fee"]=true' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" jq '.app_state["feemarket"]["params"]["min_gas_price"]="0.000000000000000000"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" @@ -72,6 +91,17 @@ bin/hnld --home "$HOMEDIR" genesis gentx alice 1000000apoa --fees ${BASEFEE}axrp bin/hnld --home "$HOMEDIR" genesis collect-gentxs +# Optionally seed bank metadata for an inbound IBC voucher before the chain ever +# starts. It has to happen here: ibc-go writes synthesised metadata on the first +# receive only when none exists, and x/bank has no MsgSetDenomMetadata, so after +# the first transfer there is no way to correct it. See +# scripts/seed-voucher-metadata.sh. +# +# SEED_VOUCHER="qbftclient-0 0xfE0B... tCeBM_BRL 'Test CeBM BRL' 18" ./local-node.sh +if [ -n "${SEED_VOUCHER:-}" ]; then + eval "scripts/seed-voucher-metadata.sh \"$GENESIS\" $SEED_VOUCHER" +fi + bin/hnld --home "$HOMEDIR" genesis validate if [[ $1 == "pending" ]]; then @@ -113,6 +143,14 @@ if [[ $1 == "pending" ]]; then grep -q -F '[memiavl]' "$APP_TOML" && sed -i '/\[memiavl\]/,/^\[/ s/enable = true/enable = false/' "$APP_TOML" fi +# Genesis is final at this point. Denom metadata must be seeded HERE: ibc-go only +# synthesises voucher metadata if none exists, and x/bank has no MsgSetDenomMetadata, +# so post-genesis there is no way to correct it (see scripts/seed-voucher-metadata.sh). +if [ -n "${SKIP_START:-}" ]; then + echo "SKIP_START set -- genesis ready at $GENESIS, not starting" + exit 0 +fi + bin/hnld start \ --metrics "$TRACE" \ --log_level $LOGLEVEL \ diff --git a/proto/aggregator/aggregator.proto b/proto/aggregator/aggregator.proto new file mode 100644 index 0000000..d309ce1 --- /dev/null +++ b/proto/aggregator/aggregator.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package aggregator; + +option go_package = "github.com/peersyst/cbdc-node/x/qbftclient/attestor/aggregatorpb"; + +// The Aggregator service definition. +service AggregatorService { + // Queries the attestor with a list of packets. Then used that attestation to + // get the state attestation. + rpc GetAttestations(GetAttestationsRequest) returns (GetAttestationsResponse); +} + +// Request message for getting an attestation for a set of packets. +message GetAttestationsRequest { + // The packets to attest to + repeated bytes packets = 1; + // The height to attest to the packets at + uint64 height = 2; +} + + +// One instance of an attestation with all signatures and public keys +message AggregatedAttestation { + // The height of the attestation + uint64 height = 1; + // The timestamp of the block + optional uint64 timestamp = 2; + // The attested data + bytes attested_data = 3; + // The attestation signatures + repeated bytes signatures = 4; +} + +// GetStateAttestationResponse is a response from the aggregator. +message GetAttestationsResponse { + // The attestation of the blockchain state + AggregatedAttestation state_attestation = 1; + // The attestation of the packet membership at the attested state + AggregatedAttestation packet_attestation = 2; +} diff --git a/proto/cbdc/params.proto b/proto/cbdc/params.proto index ad8d35a..19820b5 100644 --- a/proto/cbdc/params.proto +++ b/proto/cbdc/params.proto @@ -15,5 +15,26 @@ message Params { // authority can toggle it (via MsgUpdateParams), so mint/burn can be halted // even if the owner key is compromised. It does not affect params updates or // queries. + // + // NOTE: it does NOT stop IBC. issuance_paused is checked only in the module's + // own mint/burn path, so tokens continue to flow across a corridor while it is + // engaged. paused_ibc_clients below is the switch for that. bool issuance_paused = 2; + + // paused_ibc_clients halts IBC transfers on the listed client ids, in both + // directions: outbound transfers are refused, and inbound ones are rejected + // with an error acknowledgement so the counterparty refunds its sender rather + // than stranding the packet. + // + // It is per-client because one chain may run many corridors. Pausing all of + // them because one counterparty is in trouble is an outage, not an incident + // response. + // + // Like issuance_paused this is gov-controlled via MsgUpdateParams, which is the + // point: the alternatives available before it existed — emptying + // allowed_relayers, or a blanket send_enabled change — sat with the client + // creator or hit domestic transfers too. + // + // An empty list, the default, pauses nothing. + repeated string paused_ibc_clients = 3; } diff --git a/proto/cbdc/tx.proto b/proto/cbdc/tx.proto index 3fd25a0..2bed5e0 100644 --- a/proto/cbdc/tx.proto +++ b/proto/cbdc/tx.proto @@ -5,6 +5,7 @@ import "gogoproto/gogo.proto"; import "cosmos_proto/cosmos.proto"; import "cosmos/msg/v1/msg.proto"; import "cosmos/base/v1beta1/coin.proto"; +import "cosmos/bank/v1beta1/bank.proto"; import "amino/amino.proto"; import "cbdc/params.proto"; @@ -20,6 +21,10 @@ service Msg { rpc Burn(MsgBurn) returns (MsgBurnResponse); // Updates the module params. Only the governance authority can call it. rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse); + // Sets the bank denom metadata for a denom, overwriting any existing entry. + // Only the governance authority can call it. + rpc SetDenomMetadata(MsgSetDenomMetadata) + returns (MsgSetDenomMetadataResponse); } // MsgMint defines a message to mint CBDC tokens and send them to an address @@ -59,3 +64,19 @@ message MsgUpdateParams { } // MsgUpdateParamsResponse defines the response for updating the module params message MsgUpdateParamsResponse {} + +// MsgSetDenomMetadata defines a message to set the bank denom metadata for a +// denom +message MsgSetDenomMetadata { + option (cosmos.msg.v1.signer) = "authority"; + + // authority is the address that controls the module params (defaults to the + // gov module account) + string authority = 1 [ (cosmos_proto.scalar) = "cosmos.AddressString" ]; + // metadata is the full denom metadata to store, replacing whatever the bank + // module currently holds for its base denom + cosmos.bank.v1beta1.Metadata metadata = 2 + [ (gogoproto.nullable) = false, (amino.dont_omitempty) = true ]; +} +// MsgSetDenomMetadataResponse defines the response for setting denom metadata +message MsgSetDenomMetadataResponse {} diff --git a/proto/ibc_attestor/attestation.proto b/proto/ibc_attestor/attestation.proto new file mode 100644 index 0000000..80dd5b6 --- /dev/null +++ b/proto/ibc_attestor/attestation.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package ibc_attestor; + +option go_package = "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb"; + +// Attestation is a single attestation from a given block height for the requested data. +// +// Vendored from cosmos/ibc-attestor proto/ibc_attestor/attestation.proto. +// Only go_package differs; the wire shape must stay byte-identical or the +// generated client cannot talk to the upstream sidecar. +// +// NOTE the difference from aggregator.AggregatedAttestation, which is otherwise +// field-for-field the same: this carries ONE signature, because a single +// attestor produces one. Combining m-of-n is the aggregator's job, so callers +// wrap this as a one-element list when building AttestationProof. +message Attestation { + // The height of the attestation + uint64 height = 1; + // The timestamp of the block + optional uint64 timestamp = 2; + // The attested data + bytes attested_data = 3; + // The attestation signature + bytes signature = 4; +} diff --git a/proto/ibc_attestor/ibc_attestor.proto b/proto/ibc_attestor/ibc_attestor.proto new file mode 100644 index 0000000..06714c1 --- /dev/null +++ b/proto/ibc_attestor/ibc_attestor.proto @@ -0,0 +1,74 @@ +syntax = "proto3"; + +package ibc_attestor; + +import "ibc_attestor/attestation.proto"; + +option go_package = "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb"; + +// Vendored from cosmos/ibc-attestor proto/ibc_attestor/ibc_attestor.proto. +// Only go_package differs. This is the attestor's OWN service -- distinct from +// aggregator.AggregatorService, which is the thin interface an aggregator +// exposes to a relayer and which cannot express non-membership. Talking to the +// sidecar directly means timeouts go through commitment_type rather than a +// side-channel. + +// Service definition for retrieving attestations. +service AttestationService { + // Retrieves an attestation for a state at a given height. + rpc StateAttestation(StateAttestationRequest) returns (StateAttestationResponse); + + // Retrieves an attestation for a set of packets. + rpc PacketAttestation(PacketAttestationRequest) returns (PacketAttestationResponse); + + // Returns the latest height of the attested chain. + rpc LatestHeight(LatestHeightRequest) returns (LatestHeightResponse); +} + +// Request message for getting an attestation for a state at a given height. +message StateAttestationRequest { + // The height to attest to + uint64 height = 1; +} + +// Response message for getting an attestation for a state at a given height. +message StateAttestationResponse { + // The attestation + Attestation attestation = 1; +} + +// Commitment type for packet attestation +enum CommitmentType { + // Packet commitment (for SendPacket events) + COMMITMENT_TYPE_PACKET = 0; + // Acknowledgment commitment (for WriteAcknowledgement events) + COMMITMENT_TYPE_ACK = 1; + // Receipt commitment (for Timeout events - non-membership proof) + COMMITMENT_TYPE_RECEIPT = 2; +} + +// Request message for getting an attestation for a set of packets. +message PacketAttestationRequest { + // The packets to attest to + repeated bytes packets = 1; + // The height to attest to the packets at + uint64 height = 2; + // The type of commitment to attest (packet or acknowledgment) + // Defaults to COMMITMENT_TYPE_PACKET if not specified (for backward compatibility) + CommitmentType commitment_type = 3; +} + +// Response message for getting an attestation for a set of packets. +message PacketAttestationResponse { + // The attestation + Attestation attestation = 1; +} + +// Request message for getting the latest height. +message LatestHeightRequest {} + +// Response message for getting the latest height. +message LatestHeightResponse { + // The latest height of the attested chain + uint64 height = 1; +} diff --git a/proto/qbftclient/qbftclient.proto b/proto/qbftclient/qbftclient.proto new file mode 100644 index 0000000..72dd5a6 --- /dev/null +++ b/proto/qbftclient/qbftclient.proto @@ -0,0 +1,97 @@ +syntax = "proto3"; +package qbftclient; + +option go_package = "github.com/peersyst/cbdc-node/x/qbftclient/types"; + +// ClientState is the persistent configuration of a light client of a +// Hyperledger Besu QBFT chain. +// +// Heights are plain uint64 block numbers rather than ibc.core.client.v1.Height. +// QBFT has no revision concept -- there is no chain-halt-and-restart protocol +// that would bump one -- so the adapter maps these to revision 0 when the +// ibc-go interface asks for a Height. This also keeps the proto free of an +// ibc-go dependency, which matters because the verification core is +// deliberately version-independent. +message ClientState { + // chain_id is the EIP-155 chain id of the counterparty Besu network. It is + // not reused across networks: distinct chains sharing an id are mutually + // replayable and give the client no stable identity to key on. + uint64 chain_id = 1; + + // trusting_period is how long a consensus state stays usable, in seconds. It + // must be shorter than the counterparty's own finality assumptions and is the + // window a relayer heartbeat has to hit before the client expires. + uint64 trusting_period = 2; + + // max_clock_drift bounds how far ahead of local time a header's timestamp may + // be, in seconds. Headers beyond it are rejected rather than buffered. + uint64 max_clock_drift = 3; + + // latest_height is the highest block number this client has verified. + uint64 latest_height = 4; + + // frozen_height is the block number at which misbehaviour was detected, or 0 + // when the client is not frozen. A frozen client processes no packets and can + // only be restored through the governance recovery path. + uint64 frozen_height = 5; + + // ibc_contract_address is the 20-byte address of the IBC contract on the + // counterparty whose storage holds packet commitments. It plays the role a + // merkle prefix plays for a Cosmos counterparty: it says where in the state + // trie commitments live. + bytes ibc_contract_address = 6; +} + +// ConsensusState is what the client remembers about one verified block. +message ConsensusState { + // timestamp is the block's time in nanoseconds since the Unix epoch. Besu + // header timestamps are in seconds; the adapter scales them, because the + // ibc-go consensus-state interface is defined in nanoseconds. + uint64 timestamp = 1; + + // state_root is the block's 32-byte state root, the anchor every + // Merkle-Patricia membership proof is verified against. + bytes state_root = 2; + + // validators is the QBFT validator set this block carries, each entry a + // 20-byte address. It is the set trusted to seal the *next* header, which is + // how the client follows validator-set changes without a client migration. A + // header never authorises the set that vouches for it. + repeated bytes validators = 3; +} + +// Header is a ClientMessage carrying one Besu block header to verify. +message Header { + // rlp_header is the RLP encoding of the block header, exactly as the + // counterparty chain produced it. It is kept as an opaque blob rather than + // decomposed into fields: the header's hash is taken over this encoding, so + // re-serialising from parsed fields risks a digest that diverges from Besu's. + bytes rlp_header = 1; +} + +// StorageProof is the Merkle-Patricia material proving one storage slot of the +// counterparty's IBC contract, in the shape an eth_getProof response returns. +// +// Both halves are required: the account proof establishes the contract's storage +// root against the block's state root, and the storage proof establishes the slot +// against that storage root. Verifying the slot alone would prove it against a +// storage root nobody vouched for. +message StorageProof { + // account_proof is the list of RLP-encoded trie nodes from the state root down + // to the contract's account. + repeated bytes account_proof = 1; + // storage_proof is the list of RLP-encoded trie nodes from the contract's + // storage root down to the slot. + repeated bytes storage_proof = 2; +} + +// Misbehaviour is a ClientMessage proving the counterparty's validators +// equivocated: two headers at the same height, both carrying a valid quorum of +// committed seals, with different block hashes. QBFT is instantly final, so this +// cannot occur without validators signing conflicting blocks. +message Misbehaviour { + // header_1 is the RLP encoding of the first conflicting header. + bytes header_1 = 1; + // header_2 is the RLP encoding of the second conflicting header. + bytes header_2 = 2; +} diff --git a/scripts/besu-devnet/up.sh b/scripts/besu-devnet/up.sh new file mode 100755 index 0000000..e78f4bb --- /dev/null +++ b/scripts/besu-devnet/up.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Bring up a single-validator Besu/QBFT chain matching the cbweb3-platform +# Scenario A spoke genesis shape, so anything proven against it transfers. +# +# Genesis constants are copied verbatim from +# scenario-a/deploy/local/spoke-besu-a/config/configTemplate.json @ origin/develop +# with only chainId varied -- which is the only field the toolkit's +# renderQBFTConfig injects per country. +set -euo pipefail + +CHAIN_NAME="${CHAIN_NAME:-brazil}" +CHAIN_ID="${CHAIN_ID:-1337}" # brazil per scenario-a/samples +RPC_PORT="${RPC_PORT:-8645}" +IMAGE="hyperledger/besu:25.8.0" +DIR="${DIR:-/tmp/cbdc-besu-$CHAIN_NAME}" +CONTAINER="besu-$CHAIN_NAME" + +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +# generate-blockchain-config writes as root, so a plain rm -rf cannot clear a +# previous run's output. Clear it from inside a container instead. +mkdir -p "$DIR" +docker run --rm -v "$DIR:/data" --entrypoint sh "$IMAGE" \ + -c 'rm -rf /data/networkFiles /data/node' >/dev/null 2>&1 || true +rm -f "$DIR"/*.json 2>/dev/null || true + +# generate-blockchain-config input: the cbweb3 genesis plus the node-generation +# block the toolkit adds (count 1 -- a freshly founded spoke has one validator). +cat > "$DIR/qbftConfig.json" </dev/null && + cp -r /out /data/networkFiles + chmod -R a+rwX /data/networkFiles 2>/dev/null || true' + +VALDIR=$(find "$DIR/networkFiles/keys" -mindepth 1 -maxdepth 1 -type d | head -1) +mkdir -p "$DIR/node" +cp "$DIR/networkFiles/genesis.json" "$DIR/node/genesis.json" +cp "$VALDIR/key" "$DIR/node/key" +chmod -R a+rwX "$DIR" 2>/dev/null || true + +docker run -d --name "$CONTAINER" \ + -p "$RPC_PORT:8545" \ + -v "$DIR/node:/data" \ + "$IMAGE" \ + --data-path=/data \ + --genesis-file=/data/genesis.json \ + --node-private-key-file=/data/key \ + --rpc-http-enabled \ + --rpc-http-host=0.0.0.0 \ + --rpc-http-port=8545 \ + --rpc-http-api=ETH,NET,WEB3,QBFT,DEBUG,TXPOOL,ADMIN \ + --host-allowlist="*" \ + --rpc-http-cors-origins="*" \ + --min-gas-price=0 \ + --bonsai-historical-block-limit=43200 \ + --bonsai-trie-logs-pruning-window-size=44000 \ + >/dev/null + +echo "container: $CONTAINER rpc: http://127.0.0.1:$RPC_PORT chainId: $CHAIN_ID" +echo "validator: $(basename "$VALDIR")" +echo "waiting for blocks..." +for i in $(seq 1 45); do + H=$(curl -s -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + "http://127.0.0.1:$RPC_PORT" 2>/dev/null | jq -r '.result // empty') + if [ -n "$H" ] && [ "$H" != "0x0" ]; then + echo "producing blocks: $H ($((16#${H#0x})))" + exit 0 + fi + sleep 2 +done +echo "TIMED OUT waiting for blocks; last docker logs:" +docker logs --tail 30 "$CONTAINER" +exit 1 diff --git a/scripts/corridor/DeployCorridorHub.s.sol b/scripts/corridor/DeployCorridorHub.s.sol new file mode 100644 index 0000000..eac095c --- /dev/null +++ b/scripts/corridor/DeployCorridorHub.s.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// solhint-disable custom-errors,gas-custom-errors,function-max-lines,no-console + +import { Script } from "forge-std/Script.sol"; +import { console2 } from "forge-std/console2.sol"; + +import { ICS26Router } from "../contracts/ICS26Router.sol"; +import { ICS20Transfer } from "../contracts/ICS20Transfer.sol"; +import { ICS27GMP } from "../contracts/ICS27GMP.sol"; +import { AttestationLightClient } from "../contracts/light-clients/attestation/AttestationLightClient.sol"; +import { IICS02ClientMsgs } from "../contracts/msgs/IICS02ClientMsgs.sol"; +import { ICS20Lib } from "../contracts/utils/ICS20Lib.sol"; +import { ICS27Lib } from "../contracts/utils/ICS27Lib.sol"; +import { IBCERC20 } from "../contracts/utils/IBCERC20.sol"; +import { Escrow } from "../contracts/utils/Escrow.sol"; +import { ICS27Account } from "../contracts/utils/ICS27Account.sol"; +import { ERC1967Proxy } from "@openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { AccessManager } from "@openzeppelin-contracts/access/manager/AccessManager.sol"; +import { DeployAccessManagerWithRoles } from "./deployments/DeployAccessManagerWithRoles.sol"; + +/// @title DeployCorridorHub +/// @notice Deploys the Eureka corridor contract set onto a Besu/QBFT chain and registers the +/// counterparty light client, in the shape the cbdc-node corridor expects. +/// +/// @dev Derived from the upstream E2ETestDeploy, stripped to what this corridor uses: no SP1 +/// verifiers, no IFT, no test ERC20. ICS27GMP is still deployed because +/// accessManagerSetTargetRoles writes a role for it and would revert on address(0). +/// +/// Env: +/// ATTESTOR_ADDRESSES comma-separated attestor addresses (DEC-31: 1 for v1, 4 from v2) +/// MIN_REQUIRED_SIGS quorum (DEC-31: 1 for v1, 3 from v2) +/// INITIAL_HEIGHT counterparty height the client starts trusting +/// INITIAL_TIMESTAMP unix SECONDS for that height +/// ROLE_MANAGER DEC-25: address(0) => proof submission open to anyone +/// CLIENT_ID id to register on THIS chain, e.g. "client-0" +/// COUNTERPARTY_CLIENT_ID the cbdc-node-side client id, e.g. "qbftclient-0" +contract DeployCorridorHub is Script, DeployAccessManagerWithRoles { + function run() public { + address[] memory attestors = vm.envAddress("ATTESTOR_ADDRESSES", ","); + uint8 minSigs = uint8(vm.envUint("MIN_REQUIRED_SIGS")); + uint64 initialHeight = uint64(vm.envUint("INITIAL_HEIGHT")); + uint64 initialTimestamp = uint64(vm.envUint("INITIAL_TIMESTAMP")); + address roleManager = vm.envAddress("ROLE_MANAGER"); + string memory counterpartyClientId = vm.envString("COUNTERPARTY_CLIENT_ID"); + + vm.startBroadcast(); + + // ── IBC core, behind ERC1967 proxies ──────────────────────────────── + AccessManager accessManager = new AccessManager(msg.sender); + + address router = address( + new ERC1967Proxy( + address(new ICS26Router()), abi.encodeCall(ICS26Router.initialize, (address(accessManager))) + ) + ); + + address transfer = address( + new ERC1967Proxy( + address(new ICS20Transfer()), + abi.encodeCall( + ICS20Transfer.initialize, + (router, address(new Escrow()), address(new IBCERC20()), address(0), address(accessManager)) + ) + ) + ); + + address gmp = address( + new ERC1967Proxy( + address(new ICS27GMP()), + abi.encodeCall(ICS27GMP.initialize, (router, address(new ICS27Account()), address(accessManager))) + ) + ); + + // msg.sender takes ID_CUSTOMIZER_ROLE, which is what gates addClient with a chosen id. + accessManagerSetTargetRoles(accessManager, router, transfer, gmp, true); + accessManagerSetRoles( + accessManager, new address[](0), new address[](0), new address[](0), msg.sender, msg.sender, msg.sender + ); + + ICS26Router(router).addIBCApp(ICS20Lib.DEFAULT_PORT_ID, transfer); + ICS26Router(router).addIBCApp(ICS27Lib.DEFAULT_PORT_ID, gmp); + + // ── Counterparty light client ─────────────────────────────────────── + // With roleManager == address(0) the constructor opens PROOF_SUBMITTER_ROLE to everyone, + // so no grant to the router is needed. With a non-zero roleManager it IS needed, or every + // recv reverts AccessControlUnauthorizedAccount. + address lightClient = + address(new AttestationLightClient(attestors, minSigs, initialHeight, initialTimestamp, roleManager)); + + // The merkle prefix MUST be a single EMPTY element. ICS24Host.prefixedPath appends the path + // to the LAST prefix element and AttestationLightClient requires path.length == 1, so the + // two-element ["ibc",""] form used on the Cosmos side yields InvalidPathLength(1,2) here. + // addClient is irreversible: getting this wrong bricks the client permanently. + bytes[] memory merklePrefix = new bytes[](1); + merklePrefix[0] = bytes(""); + + // The AUTO-ID overload, deliberately. validateCustomIBCIdentifier rejects any id starting + // with "client-" or "channel-" — those prefixes are reserved for generated ids — so the + // custom-id overload cannot produce the client-N naming the corridor already uses. + string memory clientId = + ICS26Router(router).addClient(IICS02ClientMsgs.CounterpartyInfo(counterpartyClientId, merklePrefix), lightClient); + + vm.stopBroadcast(); + + console2.log("accessManager ", address(accessManager)); + console2.log("ics26Router ", router); + console2.log("ics20Transfer ", transfer); + console2.log("ics27Gmp ", gmp); + console2.log("attestationLightClient", lightClient); + console2.log("clientId ", clientId); + console2.log("counterpartyClientId ", counterpartyClientId); + } +} diff --git a/scripts/corridor/attestor-upstream.scenb.toml b/scripts/corridor/attestor-upstream.scenb.toml new file mode 100644 index 0000000..00c1af8 --- /dev/null +++ b/scripts/corridor/attestor-upstream.scenb.toml @@ -0,0 +1,51 @@ +# cosmos/ibc-attestor configuration for the Scenario B corridor. +# +# Attests cbdc-honduras_5040000-1 state to the AttestationLightClient on the +# Scenario B hub Besu (chain 1337). This replaces cmd/qbftattestor per DEC-32. +# +# Run -- RENDER FIRST. keystore_path below carries a CORRIDOR_SECRETS placeholder +# and the attestor expands nothing, so passing THIS file directly makes it look for a +# keystore whose directory is the unsubstituted placeholder itself: +# source scripts/corridor/corridor-env.sh +# cfg=$(./scripts/corridor/render-config.sh scripts/corridor/attestor-upstream.scenb.toml) +# IBC_ATTESTOR_KEYSTORE_PASSWORD="$(cat "$CORRIDOR_SECRETS/attestor-keystore.scenb.pass")" \ +# ibc_attestor server --config "$cfg" --chain-type cosmos --signer-type local +# +# The password goes in the environment, not --keystore-password: upstream's own help +# says that flag "is visible in process listings", and it is -- to every user on the box. +# +# 🔴 THE KEYSTORE MUST RECOVER 0xc08D9c8DAa48F014B9a3D1cC1DC8F83e11CF45B2. +# That address is written into AttestationLightClient's constructor and the set +# has no setter, so any other key means signatures the client rejects as an +# unknown signer -- and fixing it costs a light-client redeploy plus a +# migrateClient. scripts/corridor/keystore-import verifies this on write; there +# is no check at startup, because upstream has no way to know what it should be. +# +# 🔴 UPSTREAM IS STATELESS. It keeps no record of which (height, timestamp) it +# has already signed and cannot detect a re-genesis, so it will re-sign a height +# the light client already holds a DIFFERENT timestamp for -- which freezes the +# client permanently. cmd/qbftattestor guarded this with a durable fsync'd log +# and a block-1 hash check; nothing here replaces them. Until an external guard +# is in front of this process, treat DEC-49's procedure as load-bearing rather +# than belt-and-braces: stop the attestor before any halt, and never re-genesis +# Honduras against a live light client. + +[server] +# The port cmd/qbftproofapi dials with -attestor-grpc. Both sidecars serve +# ibc_attestor.AttestationService, so which one answers here is the whole of +# the swap -- in either direction. +listen_addr = "127.0.0.1:8093" +health_addr = "127.0.0.1:8094" + +[adapter] +# CometBFT RPC. The cosmos adapter takes only this: it reads commitments, +# receipts, acks and block timestamps over ABCI queries, and derives every +# ICS-24 path itself from the packets in the request. +url = "http://127.0.0.1:26657" + +[signer] +# Written by scripts/corridor/keystore-import from the existing hex key, so the +# attestor address is unchanged. The password is NOT read from this file -- +# upstream deliberately takes it from --keystore-password or +# IBC_ATTESTOR_KEYSTORE_PASSWORD so secrets stay out of config. +keystore_path = "@CORRIDOR_SECRETS@/attestor-keystore.scenb" diff --git a/scripts/corridor/autorelay.sh b/scripts/corridor/autorelay.sh new file mode 100644 index 0000000..ced8f47 --- /dev/null +++ b/scripts/corridor/autorelay.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Feeds new corridor packets to cosmos/ibc-relayer so relaying needs no operator. +# +# WHY THIS AND NOT relay-watcher.sh. Both close the same gap -- qbftproofapi +# builds proofs but by DEC-7 deliberately does not watch or retry. They must not +# run together: relay-watcher.sh signs cbdc-node transactions itself, and the +# relayer signs from the SAME account, so two processes would collide on the +# sequence number and the loser is dropped silently. This script signs nothing. +# It only discovers packets and calls RelayerApiService/Relay, leaving batching, +# retries and crash-resume to the relayer, which is built for them. +# +# High-water marks are durable, per direction, and advance only past a successful +# hand-off, so a restart re-offers anything unconfirmed. Re-offering is safe: the +# relayer dedupes on (client, sequence). +set -uo pipefail + +CBDC_RPC="${CBDC_RPC:-http://127.0.0.1:26657}" +BESU_RPC="${BESU_RPC:-http://127.0.0.1:8845}" +RELAYER_GRPC="${RELAYER_GRPC:-127.0.0.1:9002}" +CBDC_CHAIN="${CBDC_CHAIN:-cbdc-honduras_5040000-1}" +BESU_CHAIN="${BESU_CHAIN:-1337}" +ROUTER="${ROUTER:?ICS26Router address required}" +HNLD="${HNLD:-bin/hnld}" +STATE_DIR="${STATE_DIR:-$PWD/.corridor/autorelay}" +INTERVAL="${INTERVAL:-5}" + +# keccak256("SendPacket(string,uint256,(uint64,string,string,uint64,(string,string,string,string,bytes)[]))") +SEND_PACKET_TOPIC=0xab3a4458a269be61dfa43faa33aa7b1f5d570716f83ad078bc2ba5dab039abae + +mkdir -p "$STATE_DIR" +CBDC_MARK="$STATE_DIR/cbdc-height" +BESU_MARK="$STATE_DIR/besu-block" +[ -f "$CBDC_MARK" ] || echo 0 > "$CBDC_MARK" +[ -f "$BESU_MARK" ] || echo 0 > "$BESU_MARK" + +relay() { # relay + grpcurl -plaintext -max-time 60 -d "{\"tx_hash\":\"$1\",\"chain_id\":\"$2\"}" \ + "$RELAYER_GRPC" skip.relayer.RelayerApiService.Relay >/dev/null 2>&1 +} + +echo "autorelay: $CBDC_CHAIN <-> $BESU_CHAIN via $RELAYER_GRPC (state $STATE_DIR)" + +while true; do + # ── Honduras -> Besu ────────────────────────────────────────────────────── + # Query by event rather than scanning blocks: the MsgTransfer may be wrapped in + # a group MsgExec, in which case the proposal tx carries no packet at all and + # only the execution tx does. Searching send_packet finds the right one either + # way. + mark=$(cat "$CBDC_MARK") + tip=$(curl -s -m 5 "$CBDC_RPC/status" | jq -r '.result.sync_info.latest_block_height // empty') + if [ -n "$tip" ] && [ "$tip" -gt "$mark" ]; then + rows=$("$HNLD" query txs --query "send_packet.packet_sequence EXISTS AND tx.height>$mark" \ + --node "$CBDC_RPC" --output json 2>/dev/null \ + | jq -r '.txs[]? | select(.code == 0) | "\(.height) \(.txhash)"' 2>/dev/null) + highest=$mark + while read -r height hash; do + [ -z "${hash:-}" ] && continue + echo " -> besu height=$height tx=$hash" + if relay "$hash" "$CBDC_CHAIN"; then + [ "$height" -gt "$highest" ] && highest=$height + fi + done <<< "$rows" + # Advance only to what was handed off, never blindly to the tip: a packet in + # a block we skipped past would never be re-offered. + [ "$highest" -gt "$mark" ] && echo "$highest" > "$CBDC_MARK" + fi + + # ── Besu -> Honduras ────────────────────────────────────────────────────── + bmark=$(cat "$BESU_MARK") + btip_hex=$(curl -s -m 5 -X POST --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + -H 'Content-Type: application/json' "$BESU_RPC" | jq -r '.result // empty') + if [ -n "$btip_hex" ]; then + btip=$((btip_hex)) + if [ "$btip" -gt "$bmark" ]; then + from=$(printf '0x%x' $((bmark + 1))) + logs=$(curl -s -m 10 -X POST --data \ + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"params\":[{\"fromBlock\":\"$from\",\"toBlock\":\"$btip_hex\",\"address\":\"$ROUTER\",\"topics\":[\"$SEND_PACKET_TOPIC\"]}],\"id\":1}" \ + -H 'Content-Type: application/json' "$BESU_RPC" | jq -r '.result[]? | "\(.blockNumber) \(.transactionHash)"' 2>/dev/null) + ok=true + while read -r bh hash; do + [ -z "${hash:-}" ] && continue + echo " -> cbdc block=$((bh)) tx=$hash" + relay "$hash" "$BESU_CHAIN" || ok=false + done <<< "$logs" + $ok && echo "$btip" > "$BESU_MARK" + fi + fi + + sleep "$INTERVAL" +done diff --git a/scripts/corridor/check-corridor.sh b/scripts/corridor/check-corridor.sh new file mode 100755 index 0000000..e2e6dd1 --- /dev/null +++ b/scripts/corridor/check-corridor.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Corridor reconciliation: escrow == voucher supply, and nothing left committed. +# +# One number catches stuck packets, double-mints and relayer bugs at once, and it +# was the ground truth through every devnet run. But escrow alone LIES: escrow is +# released by the return packet's recv, while the original packet's commitment +# stays open until its ack is relayed. A monitor watching only escrow reports a +# corridor settled with money moved and commitments outstanding -- so both halves +# are checked here and either one failing is a non-zero exit. +# +# Vouchers are DISCOVERED from the chain, never configured: a configured list can +# only ever disagree with the chain it claims to describe. +# +# Usage: +# TRANSFER=0x… ROUTER=0x… scripts/corridor/check-corridor.sh [-p] +# -p emit Prometheus textfile metrics on stdout instead of a report +set -uo pipefail + +CBDC_RPC="${CBDC_RPC:-http://127.0.0.1:26657}" +BESU_RPC="${BESU_RPC:-http://127.0.0.1:8845}" +HNLD="${HNLD:-bin/hnld}" +TRANSFER="${TRANSFER:?ICS20Transfer address required}" +ROUTER="${ROUTER:?ICS26Router address required}" +CBDC_CLIENT="${CBDC_CLIENT:-qbftclient-0}" +BESU_CLIENT="${BESU_CLIENT:-client-0}" +FROM_BLOCK="${FROM_BLOCK:-0x0}" + +PROM=false +[ "${1:-}" = "-p" ] && PROM=true + +# keccak256("IBCERC20ContractCreated(address,string)") +CREATED_TOPIC=0x6031fab685dd6d86e4dbac9a69eae347145f332c95b3a0d728d3730fc5233d62 +# keccak256("SendPacket(string,uint256,(uint64,string,string,uint64,(string,string,string,string,bytes)[]))") +SEND_TOPIC=0xab3a4458a269be61dfa43faa33aa7b1f5d570716f83ad078bc2ba5dab039abae + +fail=0 +report=() +metrics=() + +rpc() { # rpc + curl -s -m 15 -X POST -H 'Content-Type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":$2,\"id\":1}" "$BESU_RPC" +} + +# ── Vouchers minted on Besu, and the escrow that must back them ─────────────── +# The trace a voucher carries is transfer//, +# so the prefix stripped here is the Besu client, not the Cosmos one. Getting +# that backwards silently produces a denom cbdc-node has never heard of, which +# then reads as "escrow 0" -- a mismatch that looks like lost money and is not. +logs=$(rpc eth_getLogs "[{\"fromBlock\":\"$FROM_BLOCK\",\"toBlock\":\"latest\",\"address\":\"$TRANSFER\",\"topics\":[\"$CREATED_TOPIC\"]}]" \ + | jq -r '.result[]? | "\(.topics[1]) \(.data)"') + +# 🔴 An empty discovery must never read as a clean corridor. Log-based discovery +# goes blind whenever the node has no receipts for the range -- observed on this +# hub 2026-08-12, where all three responding peers returned zero logs for both +# corridor contracts while their state was intact. A monitor that reports "ok" +# there is worse than no monitor: it asserts reconciliation it never performed. +# DENOMS is the escape hatch when logs are unavailable but the denoms are known. +if [ -z "${logs//[[:space:]]/}" ] && [ -z "${DENOMS:-}" ]; then + echo "FAIL discovery found no vouchers on $TRANSFER from block $FROM_BLOCK." >&2 + echo " Either none have ever been minted, or this node retains no logs for" >&2 + echo " the range -- check with eth_getLogs before trusting any 'ok' here." >&2 + echo " Set DENOMS='acbdc ucafe' to reconcile a known list instead." >&2 + exit 2 +fi + +# Explicit list wins when it is given: it is the only mode that works on a node +# whose receipts are gone. +for base in ${DENOMS:-}; do + token=$(cast call "$TRANSFER" "ibcERC20Contract(string)(address)" "transfer/$BESU_CLIENT/$base" \ + --rpc-url "$BESU_RPC" 2>/dev/null | awk '{print $1}') + [ -n "$token" ] && logs="$logs +0x000000000000000000000000${token#0x} manual:$base" +done + +while read -r topic1 data; do + [ -z "${topic1:-}" ] && continue + token="0x${topic1: -40}" + case "$data" in + manual:*) trace="transfer/$BESU_CLIENT/${data#manual:}" ;; + *) trace=$(cast abi-decode "f()(string)" "$data" 2>/dev/null | tr -d '"') ;; + esac + [ -z "$trace" ] && continue + + case "$trace" in + "transfer/$BESU_CLIENT/"*) base="${trace#transfer/$BESU_CLIENT/}" ;; + # A trace that is not prefixed by this corridor's client belongs to another + # corridor (or another hop). Escrow for it lives elsewhere; comparing it + # against this chain's escrow would invent a mismatch. + *) continue ;; + esac + + supply=$(cast call "$token" "totalSupply()(uint256)" --rpc-url "$BESU_RPC" 2>/dev/null | awk '{print $1}') + escrow=$("$HNLD" query ibc-transfer total-escrow "$base" --node "$CBDC_RPC" -o json 2>/dev/null \ + | jq -r '.amount.amount // empty') + supply="${supply:-unreadable}" + escrow="${escrow:-unreadable}" + + if [ "$supply" = "$escrow" ]; then + report+=(" ok $base escrow=$escrow == supply=$supply") + metrics+=("corridor_escrow_matches_supply{denom=\"$base\",client=\"$BESU_CLIENT\"} 1") + else + report+=(" FAIL $base escrow=$escrow != supply=$supply ($token)") + metrics+=("corridor_escrow_matches_supply{denom=\"$base\",client=\"$BESU_CLIENT\"} 0") + fail=1 + fi + # Emitted even when equal: the pair is what a human reconciles against, and a + # gauge that only appears on failure cannot be alerted on for staleness. + [ "$escrow" = "unreadable" ] || metrics+=("corridor_escrow{denom=\"$base\"} $escrow") + [ "$supply" = "unreadable" ] || metrics+=("corridor_voucher_supply{denom=\"$base\"} $supply") +done <<< "$logs" + +# ── Open commitments, both directions ──────────────────────────────────────── +cosmos_open=$("$HNLD" query ibc channelv2 packet-commitments "$CBDC_CLIENT" \ + --node "$CBDC_RPC" -o json 2>/dev/null | jq -r '.commitments | length // 0') +cosmos_open="${cosmos_open:-0}" + +# Besu has no enumerable commitment list, so replay SendPacket and ask the store +# which of those sequences still holds a commitment. Path is +# clientId || 0x01 || be64(sequence), the same ICS-24 layout the attestor hashes. +client_hex=$(printf '%s' "$BESU_CLIENT" | od -An -tx1 | tr -d ' \n') +besu_open=0 +seqs=$(rpc eth_getLogs "[{\"fromBlock\":\"$FROM_BLOCK\",\"toBlock\":\"latest\",\"address\":\"$ROUTER\",\"topics\":[\"$SEND_TOPIC\",null]}]" \ + | jq -r '.result[]? | .topics[2]') +while read -r seqhex; do + [ -z "${seqhex:-}" ] && continue + seq=$((seqhex)) + path="0x${client_hex}01$(printf '%016x' "$seq")" + c=$(cast call "$ROUTER" "getCommitment(bytes32)(bytes32)" "$(cast keccak "$path")" \ + --rpc-url "$BESU_RPC" 2>/dev/null) + case "$c" in + 0x0000000000000000000000000000000000000000000000000000000000000000|"") ;; + *) besu_open=$((besu_open + 1)) ;; + esac +done <<< "$seqs" + +metrics+=("corridor_open_commitments{chain=\"cosmos\",client=\"$CBDC_CLIENT\"} $cosmos_open") +metrics+=("corridor_open_commitments{chain=\"besu\",client=\"$BESU_CLIENT\"} $besu_open") +if [ "$cosmos_open" -gt 0 ] || [ "$besu_open" -gt 0 ]; then + report+=(" OPEN commitments: cosmos=$cosmos_open besu=$besu_open (in flight, or an ack never relayed)") + fail=1 +fi + +if $PROM; then + printf '%s\n' "${metrics[@]}" +else + echo "corridor reconciliation $CBDC_CLIENT <-> $BESU_CLIENT" + printf '%s\n' "${report[@]}" + [ "$fail" -eq 0 ] && echo " ok nothing outstanding" +fi +exit "$fail" diff --git a/scripts/corridor/corridor-env.sh b/scripts/corridor/corridor-env.sh new file mode 100755 index 0000000..96d1f78 --- /dev/null +++ b/scripts/corridor/corridor-env.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Derives the corridor's environment from the few values that are genuinely +# inputs. SOURCE it, do not execute it: +# +# source scripts/corridor/corridor-env.sh +# +# WHY DERIVE RATHER THAN LIST +# +# The previous env file asked for eighteen values. Most were not inputs at all -- +# they were facts already recorded on a chain or in a keyring, retyped by hand. +# Two of them are documented footguns *because* they are hand-entered: +# +# EVM_CHAIN_ID wrong => qbftinit encodes transactions for the wrong chain, +# and its default (1449999) is wrong for every deployment but +# the original devnet. It is literally a substring of CBDC_CHAIN. +# BESU_CHAIN wrong => the proof api and relayer disagree about which chain +# they are relaying. It is one eth_chainId call away. +# +# A value that can be read from the system it describes should be read from it. +# Anything still set by hand below is a real decision. +# +# Everything is re-derived on every source, so after `forge script` prints the +# router you set ROUTER and source again -- the three contract addresses and both +# signer addresses follow from it. + +# Sourced, so never `exit` -- that would kill the caller's shell. +__ce_fail() { echo "corridor-env: $*" >&2; return 1; } + +# ── Inputs: the only things that are genuinely decisions ───────────────────── +# Where signing material lives. Deliberately OUTSIDE the worktree: a key inside +# the checkout is one `git add -A` away from a push, and .gitignore only protects +# the filenames someone remembered to enumerate -- which is exactly how +# relayer-keys.scenb.json.bak-20260818 ended up unignored. +CORRIDOR_SECRETS="${CORRIDOR_SECRETS:-$HOME/.config/cbdc-corridor}" + +# Chain identity. The default is DEC-47's id, which the RUNNING pilot rig was +# genesised on. DEC-67 supersedes it with hnl_3402026-1 (EVM 3402026) for the +# NEXT genesis, not for live state -- so set this only against a chain actually +# genesised on that id. EVM_CHAIN_ID is derived from it below; do not set both. +CBDC_CHAIN="${CBDC_CHAIN:-cbdc-honduras_5040000-1}" +CBDC_RPC="${CBDC_RPC:-http://127.0.0.1:26657}" +BESU_RPC="${BESU_RPC:-http://127.0.0.1:8845}" +RELAYER_KEY_NAME="${RELAYER_KEY_NAME:-relayer-scenb}" +CBDC_CLIENT="${CBDC_CLIENT:-qbftclient-0}" +BESU_CLIENT="${BESU_CLIENT:-client-0}" +ROUTER="${ROUTER:-}" # auto-derived from the forge broadcast +# Pinned by digest: the image carries no revision label, so a moved tag is +# undetectable -- and :latest is currently ahead of the newest release tag, +# carrying a cosmos receipt fix that v1.1.0-rc.0 does not have. +ATTESTOR_IMAGE="${ATTESTOR_IMAGE:-ghcr.io/cosmos/ibc-attestor@sha256:eb582319b789802e7bd352988fcb6d5dc393218fb546c302ac815600f7eeb7ed}" + +CORRIDOR_HOME="${CORRIDOR_HOME:-$PWD}" +CBDC_HOME="${CBDC_HOME:-$CORRIDOR_HOME/.hnld-honduras}" +HNLD="${HNLD:-$CORRIDOR_HOME/bin/hnld}" + +# ── Derived: EVM chain id lives inside the cosmos chain id ─────────────────── +# Format is _-. Deriving it is what makes the 1449999 +# default impossible to inherit by accident. +__ce_evm=$(printf '%s' "$CBDC_CHAIN" | sed -nE 's/.*_([0-9]+)-[0-9]+$/\1/p') +[ -n "$__ce_evm" ] || __ce_fail "cannot read an EVM chain id out of CBDC_CHAIN='$CBDC_CHAIN' (expected _-)" +if [ -n "${EVM_CHAIN_ID:-}" ] && [ "$EVM_CHAIN_ID" != "$__ce_evm" ]; then + __ce_fail "EVM_CHAIN_ID is set to $EVM_CHAIN_ID but CBDC_CHAIN implies $__ce_evm -- refusing to guess which is right" +else + EVM_CHAIN_ID="$__ce_evm" +fi + +# ── Derived: Besu chain id, from the node itself ───────────────────────────── +__ce_besu=$(curl -s -m 5 -X POST -H 'content-type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' "$BESU_RPC" 2>/dev/null \ + | sed -nE 's/.*"result":"0x([0-9a-fA-F]+)".*/\1/p') +if [ -n "$__ce_besu" ]; then + __ce_besu=$((16#$__ce_besu)) + if [ -n "${BESU_CHAIN:-}" ] && [ "$BESU_CHAIN" != "$__ce_besu" ]; then + __ce_fail "BESU_CHAIN is set to $BESU_CHAIN but $BESU_RPC reports $__ce_besu" + else + BESU_CHAIN="$__ce_besu" + fi +else + BESU_CHAIN="${BESU_CHAIN:-}" # Besu not up yet; phase B fills this in +fi + +# ── The router: pinned per leg, discovered only once ───────────────────────── +# 🔴 A broadcast file on disk is NOT evidence of what this leg is running. The +# two come apart in both directions: the checkout that deployed a live corridor +# is a build artefact people delete, and a later test deploy from a different +# checkout leaves a broadcast that is perfectly valid and belongs to a different +# contract set. Discovering the router afresh on every source therefore lets an +# unrelated deploy silently re-point a live corridor -- observed, not theorised. +# +# So the leg's router is PINNED the first time it resolves, and a discovery that +# disagrees with the pin is refused rather than applied. Same discipline as the +# attestor's genesis marker: durable identity beats whatever is lying around. +__ce_pin="${LEG_DIR:-$CORRIDOR_HOME/.corridor/${BESU_CHAIN:-x}-$BESU_CLIENT}/router" +__ce_pinned="" +[ -r "$__ce_pin" ] && __ce_pinned=$(tr -d '[:space:]' < "$__ce_pin") + +if [ -z "$ROUTER" ] && [ -n "$__ce_pinned" ]; then + ROUTER="$__ce_pinned" +elif [ -z "$ROUTER" ] && [ -x "$CORRIDOR_HOME/scripts/corridor/router-from-broadcast.sh" ]; then + __ce_r=$(BESU_RPC="$BESU_RPC" BESU_CHAIN="${BESU_CHAIN:-}" BESU_CLIENT="$BESU_CLIENT" \ + "$CORRIDOR_HOME/scripts/corridor/router-from-broadcast.sh" -q 2>/dev/null) + case "$__ce_r" in 0x*) ROUTER="$__ce_r" ;; esac +fi + +if [ -n "$ROUTER" ] && [ -n "$__ce_pinned" ] \ + && [ "$(printf '%s' "$ROUTER" | tr 'A-F' 'a-f')" != "$(printf '%s' "$__ce_pinned" | tr 'A-F' 'a-f')" ]; then + __ce_fail "ROUTER is $ROUTER but this leg is pinned to $__ce_pinned ($__ce_pin). + Two different corridors. If you really are re-pointing this leg, delete the pin + deliberately -- silently switching would send packets into the wrong contract set." + ROUTER="$__ce_pinned" +elif [ -n "$ROUTER" ] && [ -z "$__ce_pinned" ] && [ -d "$(dirname "$__ce_pin")" ]; then + printf '%s\n' "$ROUTER" > "$__ce_pin" 2>/dev/null +fi + +# ── Derived: the contract set, all of it, from the router ──────────────────── +# getIBCApp("transfer") and getClient() are the registry the router +# already keeps. Recording those addresses separately would only create a second +# copy that can disagree with the chain. +if [ -n "$ROUTER" ] && command -v cast >/dev/null 2>&1; then + TRANSFER=$(cast call "$ROUTER" "getIBCApp(string)(address)" transfer --rpc-url "$BESU_RPC" 2>/dev/null | tr -d '[:space:]') + LIGHT_CLIENT=$(cast call "$ROUTER" "getClient(string)(address)" "$BESU_CLIENT" --rpc-url "$BESU_RPC" 2>/dev/null | tr -d '[:space:]') + case "$TRANSFER" in 0x0000000000000000000000000000000000000000|"") TRANSFER=""; esac + case "$LIGHT_CLIENT" in 0x0000000000000000000000000000000000000000|"") LIGHT_CLIENT=""; esac +fi + +# ── Derived: signer addresses ──────────────────────────────────────────────── +RELAYER_ADDR=$("$HNLD" keys show "$RELAYER_KEY_NAME" -a \ + --keyring-backend test --home "$CBDC_HOME" 2>/dev/null || true) + +KEYSTORE="${KEYSTORE:-$CORRIDOR_SECRETS/attestor-keystore.scenb}" +KEYSTORE_PASSWORD_FILE="${KEYSTORE_PASSWORD_FILE:-$KEYSTORE.pass}" +if [ -r "$KEYSTORE" ] && command -v jq >/dev/null 2>&1; then + # Web3 v3 keystores record the address unprefixed and lowercase. + __ce_att=$(jq -r '.address // empty' "$KEYSTORE" 2>/dev/null) + [ -n "$__ce_att" ] && ATTESTOR_ADDR="0x$__ce_att" +fi +ATTESTOR_ADDR="${ATTESTOR_ADDR:-}" + +# ── Derived: paths and ports ───────────────────────────────────────────────── +LEG_DIR="${LEG_DIR:-$CORRIDOR_HOME/.corridor/$BESU_CHAIN-$BESU_CLIENT}" +STATE_DIR="${STATE_DIR:-$LEG_DIR/attestor-state}" +ATTESTOR_GRPC="${ATTESTOR_GRPC:-127.0.0.1:8093}" +PROOF_API="${PROOF_API:-127.0.0.1:8888}" + +export CBDC_CHAIN CBDC_RPC CBDC_HOME HNLD EVM_CHAIN_ID \ + BESU_RPC BESU_CHAIN CBDC_CLIENT BESU_CLIENT \ + ROUTER TRANSFER LIGHT_CLIENT \ + RELAYER_KEY_NAME RELAYER_ADDR \ + ATTESTOR_IMAGE ATTESTOR_ADDR ATTESTOR_GRPC KEYSTORE KEYSTORE_PASSWORD_FILE \ + CORRIDOR_HOME CORRIDOR_SECRETS LEG_DIR STATE_DIR PROOF_API + +# ── Report, marking what is still missing and which step supplies it ───────── +__ce_row() { printf ' %-16s %s\n' "$1" "${2:-— (set by $3)}"; } +echo "corridor-env: $CBDC_CHAIN <-> besu ${BESU_CHAIN:-?}" +__ce_row CBDC_CLIENT "$CBDC_CLIENT" +__ce_row BESU_CLIENT "$BESU_CLIENT" +__ce_row EVM_CHAIN_ID "$EVM_CHAIN_ID" +__ce_row RELAYER_ADDR "$RELAYER_ADDR" "B4: keys add" +__ce_row ATTESTOR_ADDR "$ATTESTOR_ADDR" "C0: keystore" +__ce_row ROUTER "$ROUTER" "C2: forge script (auto-read from its broadcast)" +__ce_row TRANSFER "$TRANSFER" "C2, via ROUTER" +__ce_row LIGHT_CLIENT "$LIGHT_CLIENT" "C2, via ROUTER" +__ce_row SECRETS "$CORRIDOR_SECRETS" +unset __ce_evm __ce_besu __ce_att __ce_r __ce_pin __ce_pinned diff --git a/scripts/corridor/keystore-import/main.go b/scripts/corridor/keystore-import/main.go new file mode 100644 index 0000000..a834073 --- /dev/null +++ b/scripts/corridor/keystore-import/main.go @@ -0,0 +1,136 @@ +// Command keystore-import converts a raw hex attestor key into the Web3 Secret +// Storage (v3) keystore that cosmos/ibc-attestor's local signer reads. +// +// # WHY THIS EXISTS AS A TOOL AND NOT A ONE-LINER +// +// The attestor address is written into AttestationLightClient's constructor and +// the set has no setter, so the key CANNOT be regenerated without redeploying +// the light client and migrating the client id behind it. Upstream's `key` +// subcommand offers only `generate` and `show` -- there is no import -- so the +// only way to adopt upstream while keeping the existing address is to write the +// keystore directly. Doing that by hand during a cutover is how a key gets +// mistyped, so it lives here and verifies its own output. +// +// It refuses to write a keystore whose recovered address is not the one +// expected, which is the whole safety property: a keystore for the wrong key +// produces signatures the light client rejects as an unknown signer, and that +// failure looks identical to a dozen other things at 3am. +// +// Usage: +// +// go run ./scripts/corridor/keystore-import \ +// -in "$CORRIDOR_SECRETS/attestor-key.scenb.json" \ +// -out "$CORRIDOR_SECRETS/attestor-keystore.scenb" \ +// -password-file "$CORRIDOR_SECRETS/attestor-keystore.scenb.pass" +// +// CORRIDOR_SECRETS (default ~/.config/cbdc-corridor) is set by +// scripts/corridor/corridor-env.sh. Key material is kept outside the worktree. +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/crypto" +) + +type keyFile struct { + Address string `json:"address"` + PrivateKey string `json:"private_key"` +} + +func main() { + var ( + in = flag.String("in", "", "JSON file with {address, private_key} (required)") + out = flag.String("out", "", "keystore file to write (required)") + passIn = flag.String("password-file", "", "file holding the keystore password; generated if absent (required)") + ) + flag.Parse() + if *in == "" || *out == "" || *passIn == "" { + flag.Usage() + os.Exit(2) + } + + raw, err := os.ReadFile(*in) + if err != nil { + log.Fatalf("read %s: %v", *in, err) + } + var kf keyFile + if err := json.Unmarshal(raw, &kf); err != nil { + log.Fatalf("parse %s: %v", *in, err) + } + priv, err := crypto.HexToECDSA(strings.TrimPrefix(kf.PrivateKey, "0x")) + if err != nil { + log.Fatalf("bad private key in %s: %v", *in, err) + } + + // The expected address comes from the FILE, not from the key, so that a + // mismatch between the two is caught here rather than on-chain. + derived := crypto.PubkeyToAddress(priv.PublicKey) + if kf.Address != "" && !strings.EqualFold(kf.Address, derived.Hex()) { + log.Fatalf("refusing: %s records address %s but its private key derives %s", *in, kf.Address, derived.Hex()) + } + + password, err := loadOrCreatePassword(*passIn) + if err != nil { + log.Fatalf("password: %v", err) + } + + // StandardScryptN/P rather than the Light parameters: this key can freeze a + // light client permanently, and the keystore is written once. + k := &keystore.Key{Address: derived, PrivateKey: priv} + blob, err := keystore.EncryptKey(k, password, keystore.StandardScryptN, keystore.StandardScryptP) + if err != nil { + log.Fatalf("encrypt: %v", err) + } + + // Verify by decrypting what was actually produced, before it is written + // anywhere the attestor might read it. An unverified keystore is worse than + // none: it fails at signing time, inside a corridor, not here. + back, err := keystore.DecryptKey(blob, password) + if err != nil { + log.Fatalf("verify: cannot decrypt what was just encrypted: %v", err) + } + if back.Address != derived { + log.Fatalf("verify: keystore recovers %s, expected %s", back.Address.Hex(), derived.Hex()) + } + + if err := os.WriteFile(*out, blob, 0o600); err != nil { + log.Fatalf("write %s: %v", *out, err) + } + fmt.Printf("keystore %s\n", *out) + fmt.Printf("address %s (unchanged -- no light client redeploy needed)\n", derived.Hex()) + fmt.Printf("password %s\n", *passIn) +} + +// loadOrCreatePassword reads an existing password file or creates one with 32 +// bytes of entropy. Reusing an existing file matters for re-runs: regenerating +// the password would silently orphan a keystore already in use. +func loadOrCreatePassword(path string) (string, error) { + if b, err := os.ReadFile(path); err == nil { + p := strings.TrimSpace(string(b)) + if p == "" { + return "", fmt.Errorf("%s exists but is empty", path) + } + return p, nil + } else if !os.IsNotExist(err) { + return "", err + } + + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + p := hex.EncodeToString(buf) + if err := os.WriteFile(path, []byte(p+"\n"), 0o600); err != nil { + return "", err + } + return p, nil +} diff --git a/scripts/corridor/relay-watcher.sh b/scripts/corridor/relay-watcher.sh new file mode 100755 index 0000000..3d5590a --- /dev/null +++ b/scripts/corridor/relay-watcher.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Relays corridor packets in both directions without intervention. +# +# qbftproofapi constructs proofs; by DEC-7 it deliberately does not watch, retry +# or keep state. This is the piece that does: it polls both chains, drives every +# leg a packet needs (recv, then the ack that clears the commitment), and keeps a +# durable high-water mark per direction. +# +# It is a corridor operator's tool, not part of the chain. Nothing here signs for +# anyone but the configured relayer key, and a relayer cannot forge a transfer -- +# the proofs come from qbftproofapi and the attestations from the attestor +# sidecar, which signs only what it has independently verified against its own +# view of cbdc-node. Deliberately not named here: this script drives whichever +# attestor qbftproofapi is pointed at (upstream cosmos/ibc-attestor on the +# Scenario B leg per DEC-32, cmd/qbftattestor on the retired devnet), and it +# never talks to either one directly. +# +# Usage: +# scripts/corridor/relay-watcher.sh +# with the environment below; every value has a devnet default. +set -uo pipefail + +CBDC_RPC="${CBDC_RPC:-http://127.0.0.1:26657}" +BESU_RPC="${BESU_RPC:-http://127.0.0.1:8845}" +PROOF_API="${PROOF_API:-127.0.0.1:8888}" +ROUTER="${ROUTER:?ICS26Router address required}" +CBDC_CHAIN="${CBDC_CHAIN:-cbdc_1449999-1}" +BESU_CHAIN="${BESU_CHAIN:-1337}" +CBDC_CLIENT="${CBDC_CLIENT:-qbftclient-0}" +BESU_KEY="${BESU_KEY:?Besu private key required}" +CBDC_HOME="${CBDC_HOME:?cbdc-node home required}" +CBDC_FROM="${CBDC_FROM:-alice}" +CBDC_GAS="${CBDC_GAS:-3000000}" +HNLD="${HNLD:-bin/hnld}" +STATE_DIR="${STATE_DIR:?state directory required}" +INTERVAL="${INTERVAL:-5}" + +# SendPacket(bytes32 indexed, uint64 indexed, ...) on ICS26Router. +SEND_PACKET_TOPIC=0xab3a4458a269be61dfa43faa33aa7b1f5d570716f83ad078bc2ba5dab039abae + +mkdir -p "$STATE_DIR" +CBDC_MARK="$STATE_DIR/cbdc-height" +BESU_MARK="$STATE_DIR/besu-block" +[ -f "$CBDC_MARK" ] || echo 0 > "$CBDC_MARK" +[ -f "$BESU_MARK" ] || echo 0 > "$BESU_MARK" + +log() { echo "$(date -u +%H:%M:%S) $*"; } + +# Marks advance ONLY after a leg completes. A mark that ran ahead of a stuck +# packet would hide it forever -- the queries below are strictly "greater than", +# so nothing ever looks backwards. To recover a stuck packet, lower these files. +advance() { echo "$2" > "$1"; } + +b64_of_hex() { python3 -c "import base64,binascii,sys;print(base64.b64encode(binascii.unhexlify(sys.argv[1].removeprefix('0x'))).decode())" "$1"; } + +relay_by_tx() { # -> base64 tx on stdout, empty on failure + grpcurl -plaintext -d "$1" "$PROOF_API" proofapi.ProofApiService/RelayByTx 2>/dev/null | jq -r '.tx // empty' +} + +# The outbound response is ICS26Router calldata: submit it as an ordinary EVM tx. +submit_to_besu() { # -> tx hash on stdout + local cd; cd=0x$(python3 -c "import base64,sys;print(base64.b64decode(sys.argv[1]).hex())" "$1") + cast send "$ROUTER" "$cd" --rpc-url "$BESU_RPC" --private-key "$BESU_KEY" --legacy --json 2>/dev/null \ + | jq -r 'select(.status=="0x1") | .transactionHash' +} + +# The inbound response is a bare cosmos.tx.v1beta1.TxBody -- not a signable +# transaction. It has to be wrapped in a TxRaw, decoded, given a fee, then signed. +# Nothing upstream does this step. +submit_to_cbdc() { # -> tx hash on stdout + local raw tmp; tmp=$(mktemp -d) + raw=$(python3 - "$1" <<'PY' +import base64, sys +body = base64.b64decode(sys.argv[1]) +def varint(n): + out = bytearray() + while True: + b = n & 0x7f; n >>= 7 + out.append(b | (0x80 if n else 0)) + if not n: return bytes(out) +print(base64.b64encode(b'\x0a'+varint(len(body))+body+b'\x12'+varint(0)).decode()) +PY +) + "$HNLD" tx decode "$raw" --output json 2>/dev/null \ + | jq --arg g "$CBDC_GAS" '.auth_info.fee={amount:[],gas_limit:$g,payer:"",granter:""}' > "$tmp/tx.json" || { rm -rf "$tmp"; return 1; } + "$HNLD" tx sign "$tmp/tx.json" --from "$CBDC_FROM" --keyring-backend test --home "$CBDC_HOME" \ + --chain-id "$CBDC_CHAIN" --node "$CBDC_RPC" --output-document "$tmp/signed.json" >/dev/null 2>&1 || { rm -rf "$tmp"; return 1; } + local hash + hash=$("$HNLD" tx broadcast "$tmp/signed.json" --home "$CBDC_HOME" --node "$CBDC_RPC" --output json 2>/dev/null \ + | jq -r 'select(.code==0) | .txhash') + rm -rf "$tmp" + [ -z "$hash" ] && return 1 + + # Broadcast in sync mode returns after CheckTx -- accepted into the mempool, + # NOT committed. The ack proof that follows needs the recv COMMITTED and its + # events indexed, so asking straight away loses a race the Besu side never has + # (cast send waits for a receipt). Wait for the block before returning. + wait_for_cbdc_tx "$hash" || return 1 + echo "$hash" +} + +wait_for_cbdc_tx() { # -- returns non-zero if it never commits + local i + for i in $(seq 1 30); do + if curl -s "$CBDC_RPC/tx?hash=0x$1" 2>/dev/null | jq -e '.result.height' >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +# Honduras -> Brazil. Deliver the packet, then relay the ack back so the +# commitment (and the escrow behind it) does not sit open. Escrow release and +# commitment clearing are separate events; only the ack does the second. +poll_cbdc_to_besu() { + local mark q res height hash seq recv ack + mark=$(cat "$CBDC_MARK") + q=$(printf "send_packet.packet_source_client='%s' AND tx.height>%s" "$CBDC_CLIENT" "$mark") + res=$(curl -s -G "$CBDC_RPC/tx_search" --data-urlencode "query=\"$q\"" \ + --data-urlencode 'order_by="asc"' --data-urlencode 'per_page=20' 2>/dev/null) + [ -z "$res" ] && return + + while read -r height hash seq; do + [ -z "$hash" ] && continue + log "HN->BR packet seq=$seq at height $height ($hash)" + + recv=$(relay_by_tx "{\"src_chain\":\"$CBDC_CHAIN\",\"dst_chain\":\"$BESU_CHAIN\",\"source_tx_ids\":[\"$(b64_of_hex "$hash")\"],\"src_packet_sequences\":[$seq]}") + [ -z "$recv" ] && { log " recv proof unavailable, leaving the mark for a retry"; return; } + recv=$(submit_to_besu "$recv") + [ -z "$recv" ] && { log " recv did not land on Besu, leaving the mark for a retry"; return; } + log " delivered on Besu: $recv" + + # Ack: (src,dst) FLIPPED -- src is where the ack was WRITTEN, and the ack + # always delivers on the chain that sent the packet. + ack=$(relay_by_tx "{\"src_chain\":\"$BESU_CHAIN\",\"dst_chain\":\"$CBDC_CHAIN\",\"source_tx_ids\":[\"$(b64_of_hex "$recv")\"],\"dst_packet_sequences\":[$seq]}") + if [ -n "$ack" ] && ack=$(submit_to_cbdc "$ack") && [ -n "$ack" ]; then + log " ack cleared the commitment: $ack" + else + log " WARNING delivered but the ack did not land; commitment still open for seq=$seq" + fi + + advance "$CBDC_MARK" "$height" + done < <(echo "$res" | jq -r '.result.txs[]? | . as $t | ($t.tx_result.events[]? | select(.type=="send_packet") | .attributes[]? | select(.key=="packet_sequence") | .value) as $s | "\($t.height) \($t.hash) \($s)"' 2>/dev/null) +} + +# Brazil -> Honduras. Same shape, mirrored: the recv is a Cosmos tx and the ack +# is router calldata. +poll_besu_to_cbdc() { + local mark tip logs hash seq recv ack blk + mark=$(cat "$BESU_MARK") + tip=$(cast block-number --rpc-url "$BESU_RPC" 2>/dev/null) || return + [ -z "$tip" ] && return + [ "$mark" -ge "$tip" ] && return + + logs=$(cast rpc eth_getLogs "{\"fromBlock\":\"$(printf '0x%x' $((mark+1)))\",\"toBlock\":\"$(printf '0x%x' "$tip")\",\"address\":\"$ROUTER\",\"topics\":[\"$SEND_PACKET_TOPIC\"]}" --rpc-url "$BESU_RPC" 2>/dev/null) + [ -z "$logs" ] && return + + while read -r blk hash seq; do + [ -z "$hash" ] && continue + log "BR->HN packet seq=$seq in block $blk ($hash)" + + recv=$(relay_by_tx "{\"src_chain\":\"$BESU_CHAIN\",\"dst_chain\":\"$CBDC_CHAIN\",\"source_tx_ids\":[\"$(b64_of_hex "$hash")\"],\"src_packet_sequences\":[$seq]}") + [ -z "$recv" ] && { log " recv proof unavailable, leaving the mark for a retry"; return; } + recv=$(submit_to_cbdc "$recv") + [ -z "$recv" ] && { log " recv did not land on cbdc-node, leaving the mark for a retry"; return; } + log " delivered on cbdc-node: $recv" + + ack=$(relay_by_tx "{\"src_chain\":\"$CBDC_CHAIN\",\"dst_chain\":\"$BESU_CHAIN\",\"source_tx_ids\":[\"$(b64_of_hex "$recv")\"],\"dst_packet_sequences\":[$seq]}") + if [ -n "$ack" ] && ack=$(submit_to_besu "$ack") && [ -n "$ack" ]; then + log " ack cleared the commitment: $ack" + else + log " WARNING delivered but the ack did not land; commitment still open for seq=$seq" + fi + + advance "$BESU_MARK" "$blk" + done < <(echo "$logs" | jq -r '.[]? | "\(.blockNumber|ltrimstr("0x")|ascii_downcase) \(.transactionHash) \(.topics[2]|ltrimstr("0x"))"' 2>/dev/null \ + | while read -r b h s; do + # Skip rather than let $((16#)) abort the loop: one malformed log + # entry must not stop the other packets in the batch from being + # relayed. + [ -n "$b" ] && [ -n "$s" ] || continue + echo "$((16#$b)) $h $((16#$s))" + done) +} + +log "watching $CBDC_CHAIN <-> $BESU_CHAIN via $PROOF_API" +log " router $ROUTER, client $CBDC_CLIENT, marks in $STATE_DIR" +while true; do + poll_cbdc_to_besu + poll_besu_to_cbdc + sleep "$INTERVAL" +done diff --git a/scripts/corridor/relayer-config.scenb.yml b/scripts/corridor/relayer-config.scenb.yml new file mode 100644 index 0000000..7042010 --- /dev/null +++ b/scripts/corridor/relayer-config.scenb.yml @@ -0,0 +1,126 @@ +# cosmos/ibc-relayer configuration for the Scenario B corridor. +# +# cbdc-honduras (DEC-47 pilot) <-> cbweb3-platform Scenario B hub Besu. +# +# This is the Scenario B sibling of relayer-config.yml, which targets the +# throwaway devnet pair (cbdc_1449999-1 <-> the besu-devnet rig). Both files +# exist because the chain ids, the contract addresses and the relayer accounts +# all differ, and pointing the relayer at the wrong one fails silently rather +# than loudly. +# +# Run -- RENDER FIRST. keys_path below carries a CORRIDOR_SECRETS placeholder and +# the relayer expands nothing, so passing THIS file directly fails to find the keys: +# source scripts/corridor/corridor-env.sh +# cfg=$(./scripts/corridor/render-config.sh scripts/corridor/relayer-config.scenb.yml) +# TZ=UTC bin/relayer --config "$cfg" +# with the attestor and qbftproofapi already up -- which is what +# scripts/corridor/up-corridor.sh starts. +# +# The attestor on THIS leg is upstream cosmos/ibc-attestor (DEC-32), configured +# by attestor-upstream.scenb.toml, NOT cmd/qbftattestor. The relayer cannot tell +# the difference -- it only ever talks to qbftproofapi -- but an operator reading +# this to bring the leg up by hand can, and starting the wrong one produces +# signatures the light client rejects as an unknown signer. +# +# 🔴 TZ=UTC is not optional. The relayer stores packet deadlines as local +# wall-clock in a `timestamp without time zone` column, so any non-UTC host +# skews every timeout by its offset -- this machine is Europe/Madrid, which +# spends 1-2h of a 23h timeout budget before a packet is even sent. The same +# guard is on the Cosmos<->Cosmos devnet at scripts/ibcv2-devnet/up.sh:238. + +postgres: + hostname: 'localhost' + port: '42500' + # 🔴 Its OWN database. The relayer dedupes on (client, sequence) in + # ibcv2_transfers, and Scenario B's hub Besu regenerates genesis on every + # `startBesu.sh`, so sequences restart. Rows from a previous deployment + # collide with new packets and the new ones are SILENTLY never relayed. + # Re-create this database whenever either chain is re-genesised. + database: 'relayer_corridor_scenb' + +metrics: + prometheus_address: '0.0.0.0:48002' + +relayer_api: + address: '0.0.0.0:9002' + +ibcv2_proof_api: + # qbftproofapi. Both directions are served here: outbound it returns + # ICS26Router multicall calldata, inbound an unsigned Cosmos TxBody. + grpc_address: '127.0.0.1:8888' + grpc_tls_enabled: false + +signing: + # Keyed by chain id: 'cbdc-honduras_5040000-1' and '1337'. + # NOT committed, and NOT in the worktree -- see relayer-keys.example.json. + # The placeholder is filled in by render-config.sh; the relayer + # itself expands nothing, so do not point it at this file directly. + keys_path: '@CORRIDOR_SECRETS@/relayer-keys.scenb.json' + +chains: + honduras: + chain_name: 'honduras' + chain_id: 'cbdc-honduras_5040000-1' + type: 'cosmos' + environment: 'testnet' + gas_token_symbol: 'XRP' + gas_token_coingecko_id: null + gas_token_decimals: 18 + supported_bridges: + - ibcv2 + ibcv2: + finality_offset: 0 + ack_batch_size: 10 + ack_batch_timeout: 3s + ack_batch_concurrency: 1 + recv_batch_size: 10 + recv_batch_timeout: 3s + recv_batch_concurrency: 1 + timeout_batch_size: 10 + timeout_batch_timeout: 3s + timeout_batch_concurrency: 1 + should_relay_success_acks: true + should_relay_error_acks: true + counterparty_chains: + # this chain's client id -> the chain it tracks + qbftclient-0: '1337' + cosmos: + rpc: 'http://127.0.0.1:26657' + grpc: '127.0.0.1:9090' + grpc_tls_enabled: false + address_prefix: 'ethm' + tx_submission_delay: 0s + + brazil: + chain_name: 'brazil' + chain_id: '1337' + type: 'evm' + environment: 'testnet' + gas_token_symbol: 'ETH' + gas_token_coingecko_id: null + gas_token_decimals: 18 + supported_bridges: + - ibcv2 + ibcv2: + finality_offset: 0 + ack_batch_size: 10 + ack_batch_timeout: 3s + ack_batch_concurrency: 1 + recv_batch_size: 10 + recv_batch_timeout: 3s + recv_batch_concurrency: 1 + timeout_batch_size: 10 + timeout_batch_timeout: 3s + timeout_batch_concurrency: 1 + should_relay_success_acks: true + should_relay_error_acks: true + counterparty_chains: + client-0: 'cbdc-honduras_5040000-1' + evm: + # Scenario B hub validator. The other four hub nodes (8846-8849) are + # non-validator peers and would serve reads just as well. + rpc: 'http://127.0.0.1:8845' + contracts: + ics_26_router_address: '0x5EB5888938e3fE7b334b1838B19C1e828c5148aA' + ics_20_transfer_address: '0xBeC8a9e485a4B75d3b14249de7CA6D124fE94795' + tx_submission_delay: 0s diff --git a/scripts/corridor/relayer-config.yml b/scripts/corridor/relayer-config.yml new file mode 100644 index 0000000..485a075 --- /dev/null +++ b/scripts/corridor/relayer-config.yml @@ -0,0 +1,123 @@ +# cosmos/ibc-relayer configuration for the Besu corridor. +# +# This is what qbftproofapi was BUILT for: it is a proof-API shim so this +# relayer can drive the corridor. The relayer does the watching, batching, +# retrying and crash-resume; qbftproofapi answers exactly one RPC, +# proofapi.ProofApiService/RelayByTx, and constructs the proofs. +# +# ⚠️ This targets the RETIRED devnet pair (cbdc_1449999-1 <-> the besu-devnet +# rig). The live corridor is Scenario B -- use relayer-config.scenb.yml. Kept +# because it is the only record of that leg's wiring, and because the retired +# devnet's rows are still what the shared 'relayer' database collides against +# (see the postgres note below). +# +# Run -- RENDER FIRST. keys_path below carries a CORRIDOR_SECRETS placeholder and +# the relayer expands nothing, so passing THIS file directly fails to find the keys: +# source scripts/corridor/corridor-env.sh +# cfg=$(./scripts/corridor/render-config.sh scripts/corridor/relayer-config.yml) +# TZ=UTC bin/relayer --config "$cfg" +# with an attestor and qbftproofapi already up. +# +# This leg predates DEC-32 and was driven by cmd/qbftattestor, the first-party +# sidecar. Scenario B runs upstream cosmos/ibc-attestor instead; both serve +# ibc_attestor.AttestationService, so qbftproofapi is indifferent to which one +# answers -- see x/qbftclient/attestor/attestorpb/service.go. +# +# 🔴 TZ=UTC is not optional, on this leg either. The relayer stores packet +# deadlines as local wall-clock in a `timestamp without time zone` column, so any +# non-UTC host skews every timeout by its offset. + +postgres: + hostname: 'localhost' + port: '42500' + # 🔴 A SEPARATE database, deliberately. The relayer dedupes on (client, + # sequence) in ibcv2_transfers. The shared 'relayer' database still holds 55 + # rows from the retired devnet, including qbftclient-0 rows -- and a chain + # that has been re-genesised restarts its sequences, so those rows collide + # with new packets and the new ones are SILENTLY never relayed. Re-point or + # re-create this database whenever either chain is re-genesised. + database: 'relayer_corridor' + +metrics: + prometheus_address: '0.0.0.0:48001' + +relayer_api: + address: '0.0.0.0:9001' + +ibcv2_proof_api: + # qbftproofapi. Both directions are served here: outbound it returns + # ICS26Router multicall calldata, inbound an unsigned Cosmos TxBody. + grpc_address: '127.0.0.1:8888' + grpc_tls_enabled: false + +signing: + # Keyed by chain id: 'cbdc_1449999-1' and '1337'. NOT committed, and NOT in + # the worktree -- see relayer-keys.example.json. The placeholder is filled + # in by render-config.sh; the relayer expands nothing itself. + keys_path: '@CORRIDOR_SECRETS@/relayer-keys.json' + +chains: + honduras: + chain_name: 'honduras' + chain_id: 'cbdc_1449999-1' + type: 'cosmos' + environment: 'testnet' + gas_token_symbol: 'XRP' + gas_token_coingecko_id: null + gas_token_decimals: 18 + supported_bridges: + - ibcv2 + ibcv2: + finality_offset: 0 + ack_batch_size: 10 + ack_batch_timeout: 3s + ack_batch_concurrency: 1 + recv_batch_size: 10 + recv_batch_timeout: 3s + recv_batch_concurrency: 1 + timeout_batch_size: 10 + timeout_batch_timeout: 3s + timeout_batch_concurrency: 1 + should_relay_success_acks: true + should_relay_error_acks: true + counterparty_chains: + # this chain's client id -> the chain it tracks + qbftclient-0: '1337' + cosmos: + rpc: 'http://127.0.0.1:26657' + grpc: '127.0.0.1:9090' + grpc_tls_enabled: false + address_prefix: 'ethm' + tx_submission_delay: 0s + + brazil: + chain_name: 'brazil' + chain_id: '1337' + type: 'evm' + environment: 'testnet' + gas_token_symbol: 'ETH' + gas_token_coingecko_id: null + gas_token_decimals: 18 + supported_bridges: + - ibcv2 + ibcv2: + finality_offset: 0 + ack_batch_size: 10 + ack_batch_timeout: 3s + ack_batch_concurrency: 1 + recv_batch_size: 10 + recv_batch_timeout: 3s + recv_batch_concurrency: 1 + timeout_batch_size: 10 + timeout_batch_timeout: 3s + timeout_batch_concurrency: 1 + should_relay_success_acks: true + should_relay_error_acks: true + counterparty_chains: + client-0: 'cbdc_1449999-1' + evm: + rpc: 'http://127.0.0.1:8845' + contracts: + ics_26_router_address: '0x9a3DBCa554e9f6b9257aAa24010DA8377C57c17e' + ics_20_transfer_address: '0xfeae27388A65eE984F452f86efFEd42AaBD438FD' + tx_submission_delay: 0s diff --git a/scripts/corridor/relayer-keys.example.json b/scripts/corridor/relayer-keys.example.json new file mode 100644 index 0000000..a427826 --- /dev/null +++ b/scripts/corridor/relayer-keys.example.json @@ -0,0 +1,30 @@ +{ + "_comment": [ + "Template for the relayer's signing keys. Copy to $CORRIDOR_SECRETS/relayer-keys.json", + "(default ~/.config/cbdc-corridor) and fill in. Do NOT keep a filled-in copy here.", + "Keyed by CHAIN ID: the Cosmos chain-id string, and the EVM chain id as a number-string.", + "", + "The relayer needs its OWN accounts on both chains, not the sender's. Sharing an", + "account with whoever initiates transfers causes nonce/sequence contention: two", + "processes each track their own counter, collide, and a submission is lost. Seen on", + "this corridor -- an ack was silently dropped while its packet had already been", + "delivered, leaving a commitment open behind money that had moved.", + "", + "Get the Cosmos key with:", + " bin/hnld keys unsafe-export-eth-key relayer --keyring-backend test --home .hnld-rig", + "", + "Filled-in keys live OUTSIDE the worktree, so there is nothing here to commit by", + "accident. scripts/corridor/render-config.sh writes the relayer config that names", + "them, since the relayer expands neither ~ nor $VAR in keys_path." + ], + "cbdc_1449999-1": { + "name": "Honduras (cbdc-node) relayer", + "address": "ethm1skav49ayhurfxcudzw8ghaatshtj3zcp3mpha0", + "private_key": "" + }, + "1337": { + "name": "Brazil (Besu) relayer", + "address": "0xfe3b557e8fb62b89f4916b721be55ceb828dbd73", + "private_key": "" + } +} diff --git a/scripts/corridor/render-config.sh b/scripts/corridor/render-config.sh new file mode 100755 index 0000000..72d0004 --- /dev/null +++ b/scripts/corridor/render-config.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Renders a tracked corridor config into the leg's state dir, resolving +# @CORRIDOR_SECRETS@ to the real path. Prints the rendered path on stdout. +# +# source scripts/corridor/corridor-env.sh +# cfg=$(./scripts/corridor/render-config.sh scripts/corridor/relayer-config.scenb.yml) +# TZ=UTC ../ibc-relayer/bin/relayer --config "$cfg" +# +# WHY A RENDER STEP: neither consumer expands anything in a path. cosmos/ibc-relayer +# reads signing.keys_path with a bare os.ReadFile (cmd/relayer/main.go:226), and +# cosmos/ibc-attestor's `server` has no --keystore-path to override its config with. +# So a committed config could only name key material by a repo-relative path -- +# which is the thing we are removing. The template carries a placeholder; the real +# path is filled in at run time, into a directory that is already gitignored. +set -euo pipefail + +TEMPLATE="${1:?usage: render-config.sh