From 7f13f2b4aee1616c43f86b1f6b856752961e6194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 23 Jul 2026 09:38:40 +0200 Subject: [PATCH 01/61] feat(app): wire IBC v2 (Eureka) transfer stack and router --- app/app.go | 17 +++++++++++++ app/upgrades.go | 24 ++++++++++++++++++- tests/integration/ibc_v2_test.go | 23 ++++++++++++++++++ .../integration/cbdc/integration/keepers.go | 5 ++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/integration/ibc_v2_test.go diff --git a/app/app.go b/app/app.go index 54f4dea9..ea792bed 100644 --- a/app/app.go +++ b/app/app.go @@ -38,6 +38,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 +124,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" @@ -138,6 +140,7 @@ import ( "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 +154,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" @@ -642,6 +646,19 @@ func New( AddRoute(ibctransfertypes.ModuleName, transferStack) app.IBCKeeper.SetRouter(ibcRouter) + /**** IBC V2 ****/ + + // create IBC v2 transfer stack from bottom to top of stack + var transferStackV2 ibcapi.IBCModule + transferStackV2 = transferv2.NewIBCModule(app.TransferKeeper) + transferStackV2 = ratelimitv2.NewIBCMiddleware(app.RateLimitKeeper, transferStackV2) + transferStackV2 = erc20v2.NewIBCMiddleware(transferStackV2, app.Erc20Keeper) + + // 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() diff --git a/app/upgrades.go b/app/upgrades.go index 6cc02860..4efab5e5 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -1,3 +1,25 @@ package app -func (app *App) setupUpgradeHandlers() {} +import ( + "context" + + upgradetypes "cosmossdk.io/x/upgrade/types" + "github.com/cosmos/cosmos-sdk/types/module" +) + +// 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" + +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) + }, + ) +} diff --git a/tests/integration/ibc_v2_test.go b/tests/integration/ibc_v2_test.go new file mode 100644 index 00000000..ead64ed3 --- /dev/null +++ b/tests/integration/ibc_v2_test.go @@ -0,0 +1,23 @@ +package integration + +import ( + ibctransfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" +) + +// IBC v2 (Eureka) tests + +// TestIBCV2_TransferRouteWired verifies the IBC v2 (Eureka) wiring is active: the +// app constructs without panicking with SetRouterV2, the core IBC keeper exposes +// the v2 channel keeper, and the transfer port is routable — i.e. inbound v2 +// packets on the transfer port have a module (the transferv2 -> ratelimitv2 -> +// erc20v2 stack) to dispatch to. +func (s *TestSuite) TestIBCV2_TransferRouteWired() { + channelKeeperV2 := s.network.IBCKeeper().ChannelKeeperV2 + s.Require().NotNil(channelKeeperV2, "ChannelKeeperV2 must be constructed by ibckeeper.NewKeeper") + s.Require().NotNil(channelKeeperV2.Router, "v2 router must be set via IBCKeeper.SetRouterV2") + + s.Require().True(channelKeeperV2.Router.HasRoute(ibctransfertypes.PortID), + "v2 transfer port %q must be routable", ibctransfertypes.PortID) + s.Require().NotNil(channelKeeperV2.Router.Route(ibctransfertypes.PortID), + "v2 transfer route must resolve to a non-nil IBC module") +} diff --git a/testutil/integration/cbdc/integration/keepers.go b/testutil/integration/cbdc/integration/keepers.go index d6f34120..d51e2ae8 100644 --- a/testutil/integration/cbdc/integration/keepers.go +++ b/testutil/integration/cbdc/integration/keepers.go @@ -12,6 +12,7 @@ import ( erc20keeper "github.com/cosmos/evm/x/erc20/keeper" feemarketkeeper "github.com/cosmos/evm/x/feemarket/keeper" evmkeeper "github.com/cosmos/evm/x/vm/keeper" + ibckeeper "github.com/cosmos/ibc-go/v10/modules/core/keeper" poakeeper "github.com/peersyst/cbdc-node/x/poa/keeper" ) @@ -58,3 +59,7 @@ func (n *IntegrationNetwork) FeeMarketKeeper() feemarketkeeper.Keeper { func (n *IntegrationNetwork) PoaKeeper() poakeeper.Keeper { return n.app.PoaKeeper } + +func (n *IntegrationNetwork) IBCKeeper() *ibckeeper.Keeper { + return n.app.IBCKeeper +} From f0554da8b4e2b6c222fc3e5679926fef7417cfc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 08:30:42 +0200 Subject: [PATCH 02/61] test(ibc): add two-chain ibctesting harness and IBC v2 coverage ibctesting cannot construct this app: setupWithGenesisValSet rebuilds bank genesis with an empty metadata list and x/vm.InitGenesis then panics with "denom metadata acbdc could not be found". NewIBCCoordinator lets the existing integration network build genesis instead and fills in the TestChain fields ibctesting needs. Four obstacles it resolves, each of which would otherwise stop the first person who tries: - bank genesis metadata wiped by upstream setup - GetIBCChain leaving SenderAccount/SenderAccounts/ProposedHeader unset - chain ids not configurable (adds WithChainID; the EVM chain id is parsed out of the cosmos chain id string, so two chains need two ids) - the harness init clock running ahead of ibctesting's epoch, moving time backwards on the first block and failing client creation SetupSdkConfig is made idempotent because it ends in config.Seal(), so the second test in a binary panicked with "Config is sealed". Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 +- .../integration/ibc_v2_sender_format_test.go | 68 ++++++ tests/integration/ibc_v2_transfer_test.go | 211 ++++++++++++++++++ testutil/integration/cbdc/common/config.go | 11 + testutil/integration/cbdc/common/setup.go | 6 + .../cbdc/integration/ibctesting.go | 143 ++++++++++++ 6 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 tests/integration/ibc_v2_sender_format_test.go create mode 100644 tests/integration/ibc_v2_transfer_test.go create mode 100644 testutil/integration/cbdc/integration/ibctesting.go diff --git a/.gitignore b/.gitignore index 5fe25728..faf3b9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ release/ *.out *.html -bin/ \ No newline at end of file +bin/ +.claude/ diff --git a/tests/integration/ibc_v2_sender_format_test.go b/tests/integration/ibc_v2_sender_format_test.go new file mode 100644 index 00000000..90e75900 --- /dev/null +++ b/tests/integration/ibc_v2_sender_format_test.go @@ -0,0 +1,68 @@ +package integration + +import ( + "testing" + "time" + + sdkmath "cosmossdk.io/math" + sdktypes "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/evm/utils" + transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + ibctesting "github.com/cosmos/ibc-go/v10/testing" + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + "github.com/stretchr/testify/require" +) + +// TestIBCV2_HexSenderOnMsgTransfer pins down whether a 0x-form sender works when +// going through MsgTransfer, which is how users and the issuer API actually send. +// +// It matters because the transfer keeper is wired with the EVM address codec +// (app/app.go, SetAddressCodec) which accepts hex, but on the v2 path +// transferV2Packet passes packetData.Sender straight through as the +// MsgSendPacket signer (transfer/keeper/msg_server.go:110), and the channel v2 +// msg server parses that strictly as bech32. +func TestIBCV2_HexSenderOnMsgTransfer(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + sender := chainA.SenderAccount.GetAddress() + hexSender, err := utils.Bech32ToHexAddr(sender.String()) + require.NoError(t, err) + receiver := chainB.SenderAccount.GetAddress().String() + + amount := sdkmath.NewInt(1000) + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap + timeout := uint64(chainA.GetContext().BlockTime().Add(30 * time.Minute).Unix()) + + // Control: the same transfer with a bech32 sender must succeed on v2. + bech32Msg := transfertypes.NewMsgTransfer( + transfertypes.PortID, path.EndpointA.ClientID, + sdktypes.NewCoin(app.BaseDenom, amount), + sender.String(), receiver, + clienttypes.ZeroHeight(), timeout, "", + ) + _, err = chainA.SendMsgs(bech32Msg) + require.NoError(t, err, "bech32 sender must work on the v2 path") + + // Subject: identical transfer, sender expressed as 0x. + hexMsg := transfertypes.NewMsgTransfer( + transfertypes.PortID, path.EndpointA.ClientID, + sdktypes.NewCoin(app.BaseDenom, amount), + hexSender.Hex(), receiver, + clienttypes.ZeroHeight(), timeout, "", + ) + _, hexErr := chainA.SendMsgs(hexMsg) + t.Logf("hex sender %q -> err = %v", hexSender.Hex(), hexErr) + + require.Error(t, hexErr, + "a 0x sender must be rejected on the v2 path: transferV2Packet uses it as the "+ + "MsgSendPacket signer, which is parsed strictly as bech32. Senders must be bech32 for v2.") +} diff --git a/tests/integration/ibc_v2_transfer_test.go b/tests/integration/ibc_v2_transfer_test.go new file mode 100644 index 00000000..91872250 --- /dev/null +++ b/tests/integration/ibc_v2_transfer_test.go @@ -0,0 +1,211 @@ +package integration + +import ( + "testing" + "time" + + sdkmath "cosmossdk.io/math" + sdktypes "github.com/cosmos/cosmos-sdk/types" + 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" + ibctesting "github.com/cosmos/ibc-go/v10/testing" + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + "github.com/stretchr/testify/require" +) + +// TestIBCV2_SetupPath brings up two cbdc-node chains and completes the IBC v2 +// (Eureka) path setup: a 07-tendermint client on each side plus the +// MsgRegisterCounterparty that replaces the v1 handshake. +func TestIBCV2_SetupPath(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + require.NotEmpty(t, path.EndpointA.ClientID, "client must be created on chain A") + require.NotEmpty(t, path.EndpointB.ClientID, "client must be created on chain B") + + // The counterparty registration is what makes the v2 packet path usable. + counterpartyA, ok := chainA.App.GetIBCKeeper().ClientV2Keeper.GetClientCounterparty( + chainA.GetContext(), path.EndpointA.ClientID) + require.True(t, ok, "chain A must have a registered counterparty") + require.Equal(t, path.EndpointB.ClientID, counterpartyA.ClientId) + + counterpartyB, ok := chainB.App.GetIBCKeeper().ClientV2Keeper.GetClientCounterparty( + chainB.GetContext(), path.EndpointB.ClientID) + require.True(t, ok, "chain B must have a registered counterparty") + require.Equal(t, path.EndpointA.ClientID, counterpartyB.ClientId) +} + +// TestIBCV2_TransferAndReceive exercises a full IBC v2 transfer across two +// cbdc-node chains: send on A (escrow), relay and receive on B (voucher mint). +// This covers the receive half of the transferv2 -> ratelimitv2 -> erc20v2 stack, +// which the wiring test cannot reach. +func TestIBCV2_TransferAndReceive(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appA := chainA.App.(*app.App) + appB := chainB.App.(*app.App) + + amount := sdkmath.NewInt(1_000_000) + sender := chainA.SenderAccount.GetAddress() + receiver := chainB.SenderAccount.GetAddress() + + senderBalanceBefore := appA.BankKeeper.GetBalance(chainA.GetContext(), sender, app.BaseDenom) + require.True(t, senderBalanceBefore.Amount.GTE(amount), "sender must be funded") + + packet := sendV2Transfer(t, path, amount, sender.String(), receiver.String()) + + // Sending escrows on the source chain. + escrow := appA.TransferKeeper.GetTotalEscrowForDenom(chainA.GetContext(), app.BaseDenom) + require.Equal(t, amount, escrow.Amount, "sent amount must be escrowed on chain A") + + // Relay to chain B. + require.NoError(t, path.EndpointB.MsgRecvPacket(packet), "chain B must accept the v2 packet") + + // The voucher denom on the receiving side is prefixed with the destination + // port and, in v2, the destination client id. + voucher := transfertypes.NewDenom(app.BaseDenom, + transfertypes.NewHop(transfertypes.PortID, path.EndpointB.ClientID)) + received := appB.BankKeeper.GetBalance(chainB.GetContext(), receiver, voucher.IBCDenom()) + require.Equal(t, amount, received.Amount, "receiver must hold the minted voucher on chain B") +} + +// TestIBCV2_ReceiveRegistersERC20 asserts the erc20 v2 middleware converts an +// inbound voucher into its ERC20 representation, i.e. that a token pair is +// registered for the received IBC denom. +func TestIBCV2_ReceiveRegistersERC20(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appB := chainB.App.(*app.App) + amount := sdkmath.NewInt(500_000) + + packet := sendV2Transfer(t, path, amount, + chainA.SenderAccount.GetAddress().String(), chainB.SenderAccount.GetAddress().String()) + require.NoError(t, path.EndpointB.MsgRecvPacket(packet)) + + voucher := transfertypes.NewDenom(app.BaseDenom, + transfertypes.NewHop(transfertypes.PortID, path.EndpointB.ClientID)) + ibcDenom := voucher.IBCDenom() + + require.True(t, appB.Erc20Keeper.IsDenomRegistered(chainB.GetContext(), ibcDenom), + "erc20 v2 middleware must register a token pair for the received denom %s", ibcDenom) + + // Negative control: registration must be specific to the denom that arrived, + // otherwise the assertion above proves nothing. + require.False(t, appB.Erc20Keeper.IsDenomRegistered(chainB.GetContext(), "ibc/DEADBEEF"), + "an unrelated denom must not be registered") +} + +// TestIBCV2_RateLimitAppliesToV2Sends proves the rate-limit middleware engages on +// the v2 path and that quotas are keyed by CLIENT id, not channel id: ratelimitv2 +// converts the v2 packet into a v1 packet whose SourceChannel is the source +// client. A v1-style channel-keyed quota therefore does not cover v2 traffic. +func TestIBCV2_RateLimitAppliesToV2Sends(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appA := chainA.App.(*app.App) + sender := chainA.SenderAccount.GetAddress() + receiver := chainB.SenderAccount.GetAddress().String() + + // Quota is registered against the client id, which is what the v2 path uses. + require.NoError(t, appA.RateLimitKeeper.AddRateLimit(chainA.GetContext(), &ratelimittypes.MsgAddRateLimit{ + Denom: app.BaseDenom, + ChannelOrClientId: path.EndpointA.ClientID, + MaxPercentSend: sdkmath.NewInt(1), + MaxPercentRecv: sdkmath.NewInt(1), + DurationHours: 24, + })) + + limit, found := appA.RateLimitKeeper.GetRateLimit(chainA.GetContext(), app.BaseDenom, path.EndpointA.ClientID) + require.True(t, found, "rate limit must be stored against the client id") + require.True(t, limit.Flow.Outflow.IsZero(), "outflow starts at zero") + + // An in-quota v2 send is metered. + inQuota := sdkmath.NewInt(1_000_000) + packet := sendV2Transfer(t, path, inQuota, sender.String(), receiver) + require.NoError(t, path.EndpointB.MsgRecvPacket(packet)) + + limit, found = appA.RateLimitKeeper.GetRateLimit(chainA.GetContext(), app.BaseDenom, path.EndpointA.ClientID) + require.True(t, found) + require.Equal(t, inQuota, limit.Flow.Outflow, + "v2 send must be counted against the client-keyed quota") + + // An over-quota v2 send is rejected: 1% of channel value is far below this. + overQuota := limit.Flow.ChannelValue.QuoRaw(2) + transferData := transfertypes.NewFungibleTokenPacketData( + app.BaseDenom, overQuota.String(), sender.String(), receiver, "") + bz := chainA.Codec.MustMarshal(&transferData) + payload := channeltypesv2.NewPayload( + transfertypes.PortID, transfertypes.PortID, + transfertypes.V1, transfertypes.EncodingProtobuf, bz, + ) + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap + timeout := uint64(chainA.GetContext().BlockTime().Add(30 * time.Minute).Unix()) + + _, err := path.EndpointA.MsgSendPacket(timeout, payload) + require.Error(t, err, "over-quota v2 send must be rejected") + require.Contains(t, err.Error(), "quota") + + // The rejected send must not have moved the accounting. + limit, found = appA.RateLimitKeeper.GetRateLimit(chainA.GetContext(), app.BaseDenom, path.EndpointA.ClientID) + require.True(t, found) + require.Equal(t, inQuota, limit.Flow.Outflow, "rejected send must not be counted") +} + +// sendV2Transfer builds an ICS20 payload for the base denom and sends it from +// endpoint A over the v2 packet path, returning the resulting packet. +// +// The source chain is taken from the path rather than passed in: this always +// sends via path.EndpointA, so accepting a chain argument would let a caller pass +// chain B and silently get a packet sent from chain A but stamped with chain B's +// clock. +func sendV2Transfer( + t *testing.T, + path *ibctesting.Path, + amount sdkmath.Int, + sender, receiver string, +) channeltypesv2.Packet { + t.Helper() + + source := path.EndpointA.Chain + coin := sdktypes.NewCoin(app.BaseDenom, amount) + transferData := transfertypes.NewFungibleTokenPacketData(coin.Denom, coin.Amount.String(), sender, receiver, "") + bz := source.Codec.MustMarshal(&transferData) + payload := channeltypesv2.NewPayload( + transfertypes.PortID, transfertypes.PortID, + transfertypes.V1, transfertypes.EncodingProtobuf, bz, + ) + + // v2 timeouts are unix SECONDS and must be within MaxTimeoutDelta (24h). + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap + timeout := uint64(source.GetContext().BlockTime().Add(30 * time.Minute).Unix()) + + packet, err := path.EndpointA.MsgSendPacket(timeout, payload) + require.NoError(t, err, "v2 packet send must succeed") + return packet +} diff --git a/testutil/integration/cbdc/common/config.go b/testutil/integration/cbdc/common/config.go index 5adff1d7..42b4caa5 100644 --- a/testutil/integration/cbdc/common/config.go +++ b/testutil/integration/cbdc/common/config.go @@ -62,6 +62,17 @@ func WithAmountOfValidators(amount int) ConfigOption { } } +// WithChainID sets the cosmos chain id and its matching EIP-155 chain id for the +// network. The cosmos chain id must be in EVM format (name_EVMID-EPOCH) since the +// EVM chain id is derived from it. Needed to stand up more than one chain in a +// single test, e.g. for IBC. +func WithChainID(chainID string, eip155ChainID *big.Int) ConfigOption { + return func(cfg *Config) { + cfg.ChainID = chainID + cfg.EIP155ChainID = eip155ChainID + } +} + // WithPreFundedAccounts sets the pre-funded accounts for the network. func WithPreFundedAccounts(accounts ...sdktypes.AccAddress) ConfigOption { return func(cfg *Config) { diff --git a/testutil/integration/cbdc/common/setup.go b/testutil/integration/cbdc/common/setup.go index 9e478c34..dd7db030 100644 --- a/testutil/integration/cbdc/common/setup.go +++ b/testutil/integration/cbdc/common/setup.go @@ -72,6 +72,12 @@ func CustomizeGenesis(cbdcApp *app.App, customGen CustomGenesisState, genesisSta } func SetupSdkConfig() { + // The SDK config is global and sealed on first use, so this must be a no-op + // when another test in the same binary has already configured it. + if sdktypes.GetConfig().GetBech32AccountAddrPrefix() == app.AccountAddressPrefix { + return + } + accountPubKeyPrefix := app.AccountAddressPrefix + "pub" validatorAddressPrefix := app.AccountAddressPrefix + "valoper" validatorPubKeyPrefix := app.AccountAddressPrefix + "valoperpub" diff --git a/testutil/integration/cbdc/integration/ibctesting.go b/testutil/integration/cbdc/integration/ibctesting.go new file mode 100644 index 00000000..dcb46b57 --- /dev/null +++ b/testutil/integration/cbdc/integration/ibctesting.go @@ -0,0 +1,143 @@ +package cbdcintegration + +import ( + "math/big" + "testing" + "time" + + sdkmath "cosmossdk.io/math" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + "github.com/cosmos/evm/testutil/keyring" + feemarkettypes "github.com/cosmos/evm/x/feemarket/types" + evmtypes "github.com/cosmos/evm/x/vm/types" + ibctesting "github.com/cosmos/ibc-go/v10/testing" + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" +) + +const ( + // AccountsPerIBCChain is the number of pre-funded, signable accounts each IBC + // test chain is created with. + AccountsPerIBCChain = 3 + // IBCBondDenom is the staking denom used by the IBC test chains, matching the + // one the rest of the integration suite uses. + IBCBondDenom = "apoa" +) + +// NewIBCTestChain builds a fully initialized ibctesting.TestChain backed by a +// cbdc-node app, and registers it on the coordinator. +// +// ibctesting's own constructors (NewTestChain / NewCustomAppTestChain) cannot +// build this app: their setupWithGenesisValSet rebuilds the bank genesis with an +// empty metadata list, and x/vm's InitGenesis panics with "denom metadata acbdc +// could not be found". We therefore let the integration harness build the +// genesis -- which sets denom metadata, bond denom and EVM params correctly -- +// and then populate the TestChain fields that GetIBCChain leaves unset +// (SenderAccounts, ProposedHeader, TrustedValidators). +// +// The returned chain is driven by ibctesting from here on; do not mix in the +// IntegrationNetwork's own NextBlock helpers, as the two track block time +// separately. +func NewIBCTestChain( + t *testing.T, + coord *ibctesting.Coordinator, + chainID string, + eip155ChainID *big.Int, + opts ...cbdccommon.ConfigOption, +) (*ibctesting.TestChain, keyring.Keyring) { + t.Helper() + + kr := keyring.New(AccountsPerIBCChain) + + // The default EVM genesis denom is the upstream example chain's (aatom); x/vm + // panics at InitGenesis unless it matches a denom with bank metadata. + evmGen := evmtypes.DefaultGenesisState() + evmGen.Params.EvmDenom = app.BaseDenom + + // ibctesting signs with zero fees, so the fee market must not demand a base fee. + feemarketGen := feemarkettypes.DefaultGenesisState() + feemarketGen.Params.NoBaseFee = true + feemarketGen.Params.BaseFee = sdkmath.LegacyZeroDec() + feemarketGen.Params.MinGasPrice = sdkmath.LegacyZeroDec() + + defaults := []cbdccommon.ConfigOption{ + cbdccommon.WithChainID(chainID, eip155ChainID), + cbdccommon.WithPreFundedAccounts(kr.GetAllAccAddrs()...), + cbdccommon.WithBondDenom(IBCBondDenom), + cbdccommon.WithMaxValidators(7), + cbdccommon.WithMinDepositAmt(sdkmath.NewInt(1)), + cbdccommon.WithCustomGenesis(cbdccommon.CustomGenesisState{ + evmtypes.ModuleName: evmGen, + feemarkettypes.ModuleName: feemarketGen, + }), + } + + network := New(append(defaults, opts...)...) + chain := network.GetIBCChain(t, coord) + chain.TrustedValidators = make(map[uint64]*cmttypes.ValidatorSet) + + // Populate the sender accounts ibctesting needs to sign and deliver txs. + // GetIBCChain deliberately leaves these empty. + ctx := network.GetContext() + for i, addr := range kr.GetAllAccAddrs() { + acc := network.app.AccountKeeper.GetAccount(ctx, addr) + if acc == nil { + t.Fatalf("pre-funded account %s missing from state on %s", addr, chainID) + } + chain.SenderAccounts = append(chain.SenderAccounts, ibctesting.SenderAccount{ + SenderAccount: acc, + SenderPrivKey: kr.GetPrivKey(i), + }) + } + chain.SenderAccount = chain.SenderAccounts[0].SenderAccount + chain.SenderPrivKey = chain.SenderAccounts[0].SenderPrivKey + + coord.Chains[chainID] = chain + return chain, kr +} + +// NewIBCCoordinator stands up n cbdc-node chains wired to a single ibctesting +// Coordinator, ready for IBC v1 or v2 path setup. +// +// The coordinator clock is aligned to the chains' genesis time (the harness +// inits at time.Now(), while ibctesting's default epoch is in the past); without +// this, the first ibctesting block would move time backwards and light client +// creation would fail. +func NewIBCCoordinator(t *testing.T, n int) (*ibctesting.Coordinator, []*ibctesting.TestChain) { + t.Helper() + + coord := &ibctesting.Coordinator{ + T: t, + Chains: make(map[string]*ibctesting.TestChain), + } + + chains := make([]*ibctesting.TestChain, 0, n) + for i := 0; i < n; i++ { + // EVM-format chain id; the EVM chain id is parsed out of it. + evmID := int64(1449990 + i) + chainID := "cbdc_" + big.NewInt(evmID).String() + "-1" + chain, _ := NewIBCTestChain(t, coord, chainID, big.NewInt(evmID)) + chains = append(chains, chain) + } + + // Align the coordinator clock with the chains' genesis time. The harness inits + // each chain at time.Now(), while ibctesting's default epoch is in the past; + // without this the first ibctesting block would move time backwards and light + // client creation would fail. + coord.CurrentTime = time.Now().UTC().Add(time.Second) + + for _, chain := range chains { + chain.ProposedHeader = cmtproto.Header{ + ChainID: chain.ChainID, + Height: chain.App.LastBlockHeight() + 1, + Time: coord.CurrentTime, + } + // Commit one block so LatestCommittedHeader is populated; clients are + // created against it. + chain.NextBlock() + coord.CurrentTime = coord.CurrentTime.Add(time.Second).UTC() + } + + return coord, chains +} From 4d865fdcb6eb6a540b287f8b05446a3299ebc976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 08:30:42 +0200 Subject: [PATCH 03/61] fix(app): enforce rate limits on outbound v1 transfers The transfer keeper was constructed with ChannelKeeper as its ICS4Wrapper, so v1 sends went straight to core IBC and outbound quotas never applied. Inbound was limited; outbound was not. RateLimitKeeper itself implements ICS4Wrapper, so the fix is one WithICS4Wrapper call after construction. Verified that ics4Wrapper.SendPacket is only reached from transferV1Packet, so there is no double counting with ratelimitv2, whose accounting happens in OnSendPacket. WARNING: this changes v1 consensus behaviour. Outbound transfers become quota-enforced at the upgrade height. Genesis ships rate_limits: [] and no bootstrap script provisions any, so it should be inert on day one, but confirm with 'q ratelimit list-rate-limits' against the live node before shipping: if quotas exist they were tuned for inbound only. Kept as its own commit for that reason, and it needs its own line in the upgrade notes. Pre-existing and unrelated to IBC v2. Co-Authored-By: Claude Opus 5 (1M context) --- app/app.go | 8 +++ tests/integration/ibc_ratelimit_v1_test.go | 82 ++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/integration/ibc_ratelimit_v1_test.go diff --git a/app/app.go b/app/app.go index ea792bed..9257bb6a 100644 --- a/app/app.go +++ b/app/app.go @@ -560,6 +560,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( diff --git a/tests/integration/ibc_ratelimit_v1_test.go b/tests/integration/ibc_ratelimit_v1_test.go new file mode 100644 index 00000000..55eb725e --- /dev/null +++ b/tests/integration/ibc_ratelimit_v1_test.go @@ -0,0 +1,82 @@ +package integration + +import ( + "testing" + + sdkmath "cosmossdk.io/math" + sdktypes "github.com/cosmos/cosmos-sdk/types" + ratelimittypes "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/types" + transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + ibctesting "github.com/cosmos/ibc-go/v10/testing" + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + "github.com/stretchr/testify/require" +) + +// TestIBCV1_OutboundRateLimitEnforced guards the transfer keeper's ICS4Wrapper +// wiring. The keeper is constructed with the channel keeper as its ICS4Wrapper, +// which sends straight to core IBC; unless it is re-pointed at the rate limit +// keeper afterwards, outbound v1 transfers bypass quota enforcement entirely +// while inbound stays limited. +func TestIBCV1_OutboundRateLimitEnforced(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + // Default path config uses the mock port, which this app does not route. + path.EndpointA.ChannelConfig.PortID = transfertypes.PortID + path.EndpointB.ChannelConfig.PortID = transfertypes.PortID + path.EndpointA.ChannelConfig.Version = transfertypes.V1 + path.EndpointB.ChannelConfig.Version = transfertypes.V1 + path.Setup() // v1: connection + channel handshake + + appA := chainA.App.(*app.App) + sender := chainA.SenderAccount.GetAddress() + receiver := chainB.SenderAccount.GetAddress() + + // v1 quotas are keyed by channel id. + require.NoError(t, appA.RateLimitKeeper.AddRateLimit(chainA.GetContext(), &ratelimittypes.MsgAddRateLimit{ + Denom: app.BaseDenom, + ChannelOrClientId: path.EndpointA.ChannelID, + MaxPercentSend: sdkmath.NewInt(1), + MaxPercentRecv: sdkmath.NewInt(1), + DurationHours: 24, + })) + + limit, found := appA.RateLimitKeeper.GetRateLimit(chainA.GetContext(), app.BaseDenom, path.EndpointA.ChannelID) + require.True(t, found) + + // Over the 1% quota but within the sender's balance: on the v1 path the escrow + // (and so the balance check) happens before the ICS4Wrapper send. + overQuota := limit.Flow.ChannelValue.QuoRaw(10) + msg := transfertypes.NewMsgTransfer( + transfertypes.PortID, + path.EndpointA.ChannelID, + sdktypes.NewCoin(app.BaseDenom, overQuota), + sender.String(), + receiver.String(), + clienttypes.ZeroHeight(), + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap + uint64(chainA.GetContext().BlockTime().UnixNano())+uint64(1e12), + "", + ) + + _, err := chainA.SendMsgs(msg) + require.Error(t, err, "outbound v1 transfer above quota must be rejected") + require.Contains(t, err.Error(), "quota") + + // And an in-quota send must be metered rather than ignored. + inQuota := sdkmath.NewInt(1_000_000) + msg.Token = sdktypes.NewCoin(app.BaseDenom, inQuota) + _, err = chainA.SendMsgs(msg) + require.NoError(t, err, "in-quota outbound v1 transfer must succeed") + + limit, found = appA.RateLimitKeeper.GetRateLimit(chainA.GetContext(), app.BaseDenom, path.EndpointA.ChannelID) + require.True(t, found) + require.Equal(t, inQuota, limit.Flow.Outflow, + "outbound v1 transfer must be counted against the quota") +} From e0c129b2bded972de226f2a5066326832672abe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 08:30:42 +0200 Subject: [PATCH 04/61] feat(cbdc): add a governance corridor pause and guard unquotaed v2 sends x/cbdc's issuance_paused is checked only in the module's own mint and burn path. It does not touch the transfer module, IBC, or the bank send path, so pausing issuance does not stop a corridor: acbdc keeps flowing out and vouchers keep flowing in. For an incident where the corridor itself is the problem, the switch everyone would reach for does nothing. paused_ibc_clients is a new x/cbdc param, edited by governance through the existing MsgUpdateParams and sitting beside issuance_paused so there is one place to look during an incident. corridorpause is wired outermost in the v2 transfer stack, so a paused client is refused before anything escrows, mints or converts. - Per-corridor, not global: with N countries, pausing every corridor because one counterparty is in trouble is an outage, not an incident response. - Receives return a failure acknowledgement rather than an error, so the ack travels back and refunds the counterparty's sender. An error would strand the packet until timeout with their funds locked. - Timeouts and acknowledgements are deliberately not gated: both settle transfers that already happened, and blocking them would strand exactly the funds the pause exists to protect. - Events on both paths, so a pause is visible in the block stream and not only as a failed tx. ratelimitv2guard is added alongside because ratelimitv2 silently passes v2 sends that have no client-keyed quota. The guard makes that gap loud without blocking the packet, so a deployment that forgets client-keyed quotas cannot leave v2 outflows unlimited unnoticed. Params validation rejects empty and duplicate entries, since a proposal must not read as pausing something it does not. Co-Authored-By: Claude Opus 5 (1M context) --- app/app.go | 16 +- app/ibc/corridorpause/middleware.go | 138 ++++++++ app/ibc/corridorpause/middleware_test.go | 152 +++++++++ app/ibc/ratelimitv2guard/middleware.go | 125 ++++++++ proto/cbdc/params.proto | 21 ++ tests/integration/ibc_ratelimit_v2_test.go | 357 +++++++++++++++++++++ x/cbdc/keeper/params.go | 10 + x/cbdc/types/params.go | 42 ++- x/cbdc/types/params.pb.go | 100 +++++- x/cbdc/types/params_test.go | 37 +++ 10 files changed, 981 insertions(+), 17 deletions(-) create mode 100644 app/ibc/corridorpause/middleware.go create mode 100644 app/ibc/corridorpause/middleware_test.go create mode 100644 app/ibc/ratelimitv2guard/middleware.go create mode 100644 tests/integration/ibc_ratelimit_v2_test.go diff --git a/app/app.go b/app/app.go index 9257bb6a..a52f75d3 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" @@ -656,12 +658,24 @@ func New( /**** IBC V2 ****/ - // create IBC v2 transfer stack from bottom to top of stack + // 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 silently passes v2 sends that have no client-keyed quota. The + // guard wraps it to make that gap loud (event + error log) without blocking + // the packet, so a v2 upgrade that forgets client-keyed quotas cannot leave + // outflows unlimited unnoticed. + transferStackV2 = ratelimitv2guard.NewIBCMiddleware(app.RateLimitKeeper, transferStackV2) transferStackV2 = erc20v2.NewIBCMiddleware(transferStackV2, app.Erc20Keeper) + // The governance emergency stop for a corridor, outermost so a paused client is + // refused before any inner middleware escrows, mints or converts. x/cbdc's + // issuance_paused does not reach IBC — it is checked only in that module's own + // mint and burn path — so without this there is no gov-held lever that stops a + // corridor without also stopping domestic transfers. + 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) diff --git a/app/ibc/corridorpause/middleware.go b/app/ibc/corridorpause/middleware.go new file mode 100644 index 00000000..75e555c9 --- /dev/null +++ b/app/ibc/corridorpause/middleware.go @@ -0,0 +1,138 @@ +// 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 +// misbehaviour 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" +) + +// Events emitted when a corridor pause takes effect. They exist so a pause is +// visible in the block stream rather than only as a failed transaction. +const ( + EventTypeSendPaused = "ibc_corridor_send_paused" + 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 honour 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) { + ctx.EventManager().EmitEvent(sdk.NewEvent( + EventTypeSendPaused, + sdk.NewAttribute(AttributeKeyClientID, sourceClient), + sdk.NewAttribute(AttributeKeySequence, fmt.Sprint(sequence)), + )) + 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 00000000..2c9d8c0a --- /dev/null +++ b/app/ibc/corridorpause/middleware_test.go @@ -0,0 +1,152 @@ +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") + } + + // The pause has to be visible in the block stream, not only as a failed tx. + if len(ctx.EventManager().Events()) == 0 { + t.Error("a refused send must emit an event") + } +} + +// 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") + } + if len(ctx.EventManager().Events()) == 0 { + t.Error("a refused receive must emit an event") + } +} + +// 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 00000000..eba774e8 --- /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/proto/cbdc/params.proto b/proto/cbdc/params.proto index ad8d35ae..19820b5f 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/tests/integration/ibc_ratelimit_v2_test.go b/tests/integration/ibc_ratelimit_v2_test.go new file mode 100644 index 00000000..f5115a74 --- /dev/null +++ b/tests/integration/ibc_ratelimit_v2_test.go @@ -0,0 +1,357 @@ +package integration + +import ( + "testing" + "time" + + sdkmath "cosmossdk.io/math" + abcitypes "github.com/cometbft/cometbft/abci/types" + sdktypes "github.com/cosmos/cosmos-sdk/types" + ratelimittypes "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/types" + transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + channeltypes "github.com/cosmos/ibc-go/v10/modules/core/04-channel/types" + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + ibctesting "github.com/cosmos/ibc-go/v10/testing" + "github.com/peersyst/cbdc-node/app" + "github.com/peersyst/cbdc-node/app/ibc/ratelimitv2guard" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + "github.com/stretchr/testify/require" +) + +// TestIBCV2_RateLimitAppliesToMsgTransfer covers the send path users and the +// issuer actually take: MsgTransfer with a CLIENT id, which the transfer keeper +// auto-routes to v2. TestIBCV2_RateLimitAppliesToV2Sends drives MsgSendPacket +// directly and so skips the transfer msg server entirely. +// +// The exact-equality assertion is the point. MsgTransfer's v1 branch sends via +// the keeper's ICS4Wrapper -- which app.go re-points at the rate limit keeper -- +// while its v2 branch (transferV2Packet) dispatches a MsgSendPacket through the +// msg router instead. If that ever changed to go through the ICS4Wrapper too, +// a v2 send would be metered twice: once there and once in ratelimitv2's +// OnSendPacket. Outflow would come back as 2*amount and this test would fail. +func TestIBCV2_RateLimitAppliesToMsgTransfer(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appA := chainA.App.(*app.App) + sender := chainA.SenderAccount.GetAddress() + receiver := chainB.SenderAccount.GetAddress().String() + + addRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID, 1, 1) + + amount := sdkmath.NewInt(1_000_000) + _, err := chainA.SendMsgs(v2MsgTransfer(chainA, path.EndpointA.ClientID, amount, sender.String(), receiver)) + require.NoError(t, err, "in-quota MsgTransfer over a client id must succeed") + + limit := getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID) + require.Equal(t, amount, limit.Flow.Outflow, + "MsgTransfer on the v2 path must be metered exactly once") + + // Over quota: 1% of channel value is far below this. The v2 rate limit check + // runs before transferv2 escrows, so this trips on quota, not on balance. + overQuota := limit.Flow.ChannelValue.QuoRaw(2) + _, err = chainA.SendMsgs(v2MsgTransfer(chainA, path.EndpointA.ClientID, overQuota, sender.String(), receiver)) + require.Error(t, err, "over-quota MsgTransfer must be rejected") + require.Contains(t, err.Error(), "quota") + + limit = getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID) + require.Equal(t, amount, limit.Flow.Outflow, "rejected MsgTransfer must not be counted") +} + +// TestIBCV2_RateLimitMetersV2Receives covers the inbound half of the v2 stack: +// ratelimitv2.OnRecvPacket must count a delivered packet against the receiving +// chain's client-keyed quota. Nothing else in the suite exercises inbound +// metering -- it had only been checked by hand on the devnet. +// +// The quota is registered on chain B against the VOUCHER denom, because that is +// what ParseDenomFromRecvPacket derives for a sink chain: the ibc hash of +// transfer//. +func TestIBCV2_RateLimitMetersV2Receives(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appB := chainB.App.(*app.App) + sender := chainA.SenderAccount.GetAddress().String() + receiver := chainB.SenderAccount.GetAddress() + + // A quota cannot be added against a denom with zero supply (ErrZeroChannelValue), + // so seed the voucher with one delivered transfer first. + seed := sdkmath.NewInt(100_000_000) + packet := sendV2Transfer(t, path, seed, sender, receiver.String()) + require.NoError(t, path.EndpointB.MsgRecvPacket(packet)) + + voucher := v2VoucherDenom(path.EndpointB.ClientID) + addRateLimit(t, appB, chainB, voucher, path.EndpointB.ClientID, 50, 50) + + limit := getRateLimit(t, appB, chainB, voucher, path.EndpointB.ClientID) + require.Equal(t, seed, limit.Flow.ChannelValue, "channel value is the voucher supply at registration") + require.True(t, limit.Flow.Inflow.IsZero(), "inflow starts at zero") + + // In quota: 10% of a 50% allowance. + inQuota := sdkmath.NewInt(10_000_000) + packet = sendV2Transfer(t, path, inQuota, sender, receiver.String()) + require.NoError(t, path.EndpointB.MsgRecvPacket(packet)) + + limit = getRateLimit(t, appB, chainB, voucher, path.EndpointB.ClientID) + require.Equal(t, inQuota, limit.Flow.Inflow, + "a delivered v2 packet must be counted against the receiving client's quota") + require.Equal(t, seed.Add(inQuota), + appB.BankKeeper.GetBalance(chainB.GetContext(), receiver, voucher).Amount, + "receiver must hold both vouchers") +} + +// TestIBCV2_RateLimitUndoneOnErrorAck is the round trip the devnet checked by +// hand and nothing guarded: an over-quota RECEIVE on chain B must not error the +// relayer's tx but write an error acknowledgement, and relaying that ack back to +// chain A must give the sender their funds AND their quota back. +// +// Without the undo, a remote rejection would permanently consume the sender's +// outbound quota for something that never moved. +func TestIBCV2_RateLimitUndoneOnErrorAck(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appA := chainA.App.(*app.App) + appB := chainB.App.(*app.App) + sender := chainA.SenderAccount.GetAddress() + receiver := chainB.SenderAccount.GetAddress() + + // Seed the voucher so a receive-side quota can be registered against it. + seed := sdkmath.NewInt(100_000_000) + packet := sendV2Transfer(t, path, seed, sender.String(), receiver.String()) + require.NoError(t, path.EndpointB.MsgRecvPacket(packet)) + + voucher := v2VoucherDenom(path.EndpointB.ClientID) + + // Chain B accepts at most 1% inbound; chain A allows 50% outbound. The next + // transfer is therefore fine to send and impossible to receive. + addRateLimit(t, appB, chainB, voucher, path.EndpointB.ClientID, 1, 1) + addRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID, 50, 50) + + amount := sdkmath.NewInt(50_000_000) + senderBefore := appA.BankKeeper.GetBalance(chainA.GetContext(), sender, app.BaseDenom).Amount + voucherBefore := appB.BankKeeper.GetBalance(chainB.GetContext(), receiver, voucher).Amount + + packet = sendV2Transfer(t, path, amount, sender.String(), receiver.String()) + + limitA := getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID) + require.Equal(t, amount, limitA.Flow.Outflow, "send must be metered before the receive is attempted") + require.Equal(t, senderBefore.Sub(amount), + appA.BankKeeper.GetBalance(chainA.GetContext(), sender, app.BaseDenom).Amount, + "sender must be debited on send") + + // The receive is rejected by chain B's quota. In v2 this is a soft failure: + // the tx succeeds and the sentinel error acknowledgement is written. + require.NoError(t, path.EndpointB.MsgRecvPacket(packet), + "an over-quota receive must not error the relayer's tx, it must write an error ack") + require.Equal(t, voucherBefore, + appB.BankKeeper.GetBalance(chainB.GetContext(), receiver, voucher).Amount, + "no voucher may be minted for a rejected receive") + require.True(t, getRateLimit(t, appB, chainB, voucher, path.EndpointB.ClientID).Flow.Inflow.IsZero(), + "a rejected receive must not be counted as inflow") + + // Relay the error ack home. + errorAck := channeltypesv2.NewAcknowledgement(channeltypesv2.ErrorAcknowledgement[:]) + require.NoError(t, path.EndpointA.MsgAcknowledgePacket(packet, errorAck)) + + limitA = getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID) + require.True(t, limitA.Flow.Outflow.IsZero(), + "an error acknowledgement must undo the outflow, not leave the quota consumed") + require.Equal(t, senderBefore, + appA.BankKeeper.GetBalance(chainA.GetContext(), sender, app.BaseDenom).Amount, + "sender must be refunded") + + // Negative control: the undo must be specific to error acks. A transfer that + // is actually delivered must keep consuming its quota -- otherwise every + // successful transfer would hand the allowance straight back and the outbound + // limit would never bind at all. + delivered := sdkmath.NewInt(500_000) // within chain B's 1% inbound allowance + packet = sendV2Transfer(t, path, delivered, sender.String(), receiver.String()) + require.NoError(t, path.EndpointB.MsgRecvPacket(packet)) + + successAck := channeltypesv2.NewAcknowledgement( + channeltypes.NewResultAcknowledgement([]byte{byte(1)}).Acknowledgement()) + require.NoError(t, path.EndpointA.MsgAcknowledgePacket(packet, successAck), + "the reconstructed success ack must match what chain B committed") + + require.Equal(t, delivered, + getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID).Flow.Outflow, + "a successful acknowledgement must NOT undo the outflow") +} + +// TestIBCV2_RateLimitUndoneOnTimeout is the other half of the undo: a packet +// nobody ever delivers must release the sender's quota when it times out, or a +// dead corridor would silently eat the outbound allowance. +func TestIBCV2_RateLimitUndoneOnTimeout(t *testing.T) { + cbdccommon.SetupSdkConfig() + + coord, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appA := chainA.App.(*app.App) + sender := chainA.SenderAccount.GetAddress() + receiver := chainB.SenderAccount.GetAddress().String() + + addRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID, 50, 50) + + amount := sdkmath.NewInt(1_000_000) + senderBefore := appA.BankKeeper.GetBalance(chainA.GetContext(), sender, app.BaseDenom).Amount + + packet := sendV2Transfer(t, path, amount, sender.String(), receiver) + require.Equal(t, amount, getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID).Flow.Outflow) + + // Move both chains past the packet's timeout without ever relaying it, then + // refresh chain A's view of chain B so the non-receipt proof is provable at a + // height whose timestamp is after the timeout. + coord.IncrementTimeBy(2 * time.Hour) + coord.CommitBlock(chainA, chainB) + require.NoError(t, path.EndpointA.UpdateClient()) + + // Advancing the clock must not be what clears the outflow -- the quota window + // is 24h, so it is still open here. Without this the assertion below could + // pass on an epoch reset rather than on the timeout undo. + require.Equal(t, amount, getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID).Flow.Outflow, + "outflow must still be held before the timeout is relayed") + + require.NoError(t, path.EndpointA.MsgTimeoutPacket(packet)) + + require.True(t, getRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID).Flow.Outflow.IsZero(), + "a timed-out packet must release the outflow it reserved") + require.Equal(t, senderBefore, + appA.BankKeeper.GetBalance(chainA.GetContext(), sender, app.BaseDenom).Amount, + "sender must be refunded on timeout") +} + +// v2MsgTransfer builds a MsgTransfer aimed at a client id, which the transfer +// keeper auto-routes to v2. Note the timeout is unix SECONDS here; the v1 form of +// the same field is nanoseconds. +func v2MsgTransfer(source *ibctesting.TestChain, clientID string, amount sdkmath.Int, sender, receiver string) *transfertypes.MsgTransfer { + return transfertypes.NewMsgTransfer( + transfertypes.PortID, clientID, + sdktypes.NewCoin(app.BaseDenom, amount), + sender, receiver, + clienttypes.ZeroHeight(), + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap + uint64(source.GetContext().BlockTime().Add(30*time.Minute).Unix()), + "", + ) +} + +// v2VoucherDenom is the ibc denom a base-denom transfer lands as on the receiving +// chain. In v2 the hop is the destination CLIENT id, not a channel id. +func v2VoucherDenom(destClientID string) string { + return transfertypes.NewDenom(app.BaseDenom, + transfertypes.NewHop(transfertypes.PortID, destClientID)).IBCDenom() +} + +func addRateLimit(t *testing.T, a *app.App, chain *ibctesting.TestChain, denom, clientID string, maxSend, maxRecv int64) { + t.Helper() + require.NoError(t, a.RateLimitKeeper.AddRateLimit(chain.GetContext(), &ratelimittypes.MsgAddRateLimit{ + Denom: denom, + ChannelOrClientId: clientID, + MaxPercentSend: sdkmath.NewInt(maxSend), + MaxPercentRecv: sdkmath.NewInt(maxRecv), + DurationHours: 24, + })) +} + +func getRateLimit(t *testing.T, a *app.App, chain *ibctesting.TestChain, denom, clientID string) ratelimittypes.RateLimit { + t.Helper() + limit, found := a.RateLimitKeeper.GetRateLimit(chain.GetContext(), denom, clientID) + require.True(t, found, "rate limit for %s on %s must exist", denom, clientID) + return limit +} + +// TestIBCV2_UnratelimitedSendIsLoud covers the deploy-time trap: v2 rate limits +// are keyed by CLIENT id, separate from v1's channel-keyed quotas, so a v2 upgrade +// that forgets to add them leaves every v2 outflow unmetered -- and upstream +// ratelimitv2 stays completely silent about it (keeper/flow.go: "If there's no +// rate limit yet for this denom, no action is necessary"). ratelimitv2guard turns +// that silence into an event without blocking the transfer. +func TestIBCV2_UnratelimitedSendIsLoud(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + sender := chainA.SenderAccount.GetAddress().String() + receiver := chainB.SenderAccount.GetAddress().String() + amount := sdkmath.NewInt(1_000_000) + + // No rate limit configured: the send must still succeed AND announce the gap. + res, err := chainA.SendMsgs(v2MsgTransfer(chainA, path.EndpointA.ClientID, amount, sender, receiver)) + require.NoError(t, err, "an unratelimited v2 send must not be blocked") + require.True(t, hasUnratelimitedSendEvent(res.Events, app.BaseDenom, path.EndpointA.ClientID), + "an uncovered v2 outflow must emit the loud signal event") +} + +// TestIBCV2_RatelimitedSendIsQuiet is the negative control: once a client-keyed +// quota exists, the covered send must NOT emit the gap signal -- otherwise the +// signal would fire on every transfer and operators would learn to ignore it. +func TestIBCV2_RatelimitedSendIsQuiet(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 2) + chainA, chainB := chains[0], chains[1] + + path := ibctesting.NewPath(chainA, chainB) + path.SetupV2() + + appA := chainA.App.(*app.App) + sender := chainA.SenderAccount.GetAddress().String() + receiver := chainB.SenderAccount.GetAddress().String() + + addRateLimit(t, appA, chainA, app.BaseDenom, path.EndpointA.ClientID, 50, 50) + + amount := sdkmath.NewInt(1_000_000) + res, err := chainA.SendMsgs(v2MsgTransfer(chainA, path.EndpointA.ClientID, amount, sender, receiver)) + require.NoError(t, err) + require.False(t, hasUnratelimitedSendEvent(res.Events, app.BaseDenom, path.EndpointA.ClientID), + "a send covered by a client-keyed quota must not emit the gap signal") +} + +// hasUnratelimitedSendEvent reports whether the tx emitted the guard's gap signal +// for the given denom and client. +func hasUnratelimitedSendEvent(events []abcitypes.Event, denom, clientID string) bool { + for _, e := range events { + if e.Type != ratelimitv2guard.EventTypeUnratelimitedSend { + continue + } + var gotDenom, gotClient string + for _, a := range e.Attributes { + switch a.Key { + case ratelimitv2guard.AttributeKeyDenom: + gotDenom = a.Value + case ratelimitv2guard.AttributeKeyClientID: + gotClient = a.Value + } + } + if gotDenom == denom && gotClient == clientID { + return true + } + } + return false +} diff --git a/x/cbdc/keeper/params.go b/x/cbdc/keeper/params.go index 52ec7454..3f659f6e 100644 --- a/x/cbdc/keeper/params.go +++ b/x/cbdc/keeper/params.go @@ -15,3 +15,13 @@ func (k Keeper) GetParams(ctx sdk.Context) (params types.Params) { func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { k.paramstore.SetParamSet(ctx, ¶ms) } + +// IsIBCClientPaused reports whether governance has halted transfers on clientID. +// +// It is the read side of the corridor emergency stop; app/ibc/corridorpause holds +// the enforcement, and the parameter it reads is gov-controlled via +// MsgUpdateParams — deliberately, since the alternatives all sat with the wrong +// authority or were too blunt. +func (k Keeper) IsIBCClientPaused(ctx sdk.Context, clientID string) bool { + return k.GetParams(ctx).IsIBCClientPaused(clientID) +} diff --git a/x/cbdc/types/params.go b/x/cbdc/types/params.go index dceaabcc..86d16735 100644 --- a/x/cbdc/types/params.go +++ b/x/cbdc/types/params.go @@ -2,6 +2,7 @@ package types import ( "fmt" + "slices" sdk "github.com/cosmos/cosmos-sdk/types" paramtypes "github.com/cosmos/cosmos-sdk/x/params/types" @@ -15,6 +16,8 @@ var ( KeyOwner = []byte("Owner") // KeyIssuancePaused is the param store key for the issuance pause switch. KeyIssuancePaused = []byte("IssuancePaused") + // KeyPausedIBCClients is the param store key for the per-corridor IBC pause. + KeyPausedIBCClients = []byte("PausedIbcClients") ) // ParamKeyTable the param key table for launch module @@ -22,9 +25,10 @@ func ParamKeyTable() paramtypes.KeyTable { return paramtypes.NewKeyTable().RegisterParamSet(&Params{}) } -// NewParams creates a new Params instance -func NewParams(owner string, issuancePaused bool) Params { - return Params{Owner: owner, IssuancePaused: issuancePaused} +// NewParams creates a new Params instance. pausedIBCClients may be nil, which +// pauses no corridor. +func NewParams(owner string, issuancePaused bool, pausedIBCClients ...string) Params { + return Params{Owner: owner, IssuancePaused: issuancePaused, PausedIbcClients: pausedIBCClients} } // DefaultParams returns a default set of parameters. The owner is intentionally @@ -39,6 +43,7 @@ func (p *Params) ParamSetPairs() paramtypes.ParamSetPairs { return paramtypes.ParamSetPairs{ paramtypes.NewParamSetPair(KeyOwner, &p.Owner, validateOwner), paramtypes.NewParamSetPair(KeyIssuancePaused, &p.IssuancePaused, validateIssuancePaused), + paramtypes.NewParamSetPair(KeyPausedIBCClients, &p.PausedIbcClients, validatePausedIBCClients), } } @@ -47,7 +52,15 @@ func (p *Params) Validate() error { if err := validateOwner(p.Owner); err != nil { return err } - return validateIssuancePaused(p.IssuancePaused) + if err := validateIssuancePaused(p.IssuancePaused); err != nil { + return err + } + return validatePausedIBCClients(p.PausedIbcClients) +} + +// IsIBCClientPaused reports whether transfers on clientID are halted. +func (p Params) IsIBCClientPaused(clientID string) bool { + return slices.Contains(p.PausedIbcClients, clientID) } // validateOwner requires a non-empty, well-formed bech32 address. The owner is @@ -74,3 +87,24 @@ func validateIssuancePaused(i interface{}) error { } return nil } + +// validatePausedIBCClients rejects empty and duplicate entries. Neither is +// meaningful, and both would make a governance proposal read as doing something +// it does not do. +func validatePausedIBCClients(i interface{}) error { + clients, ok := i.([]string) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + seen := make(map[string]struct{}, len(clients)) + for idx, c := range clients { + if c == "" { + return fmt.Errorf("paused_ibc_clients[%d] must not be empty", idx) + } + if _, dup := seen[c]; dup { + return fmt.Errorf("paused_ibc_clients contains %q twice", c) + } + seen[c] = struct{}{} + } + return nil +} diff --git a/x/cbdc/types/params.pb.go b/x/cbdc/types/params.pb.go index 48939a43..03a1851d 100644 --- a/x/cbdc/types/params.pb.go +++ b/x/cbdc/types/params.pb.go @@ -33,7 +33,27 @@ type Params struct { // 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. IssuancePaused bool `protobuf:"varint,2,opt,name=issuance_paused,json=issuancePaused,proto3" json:"issuance_paused,omitempty"` + // 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. + PausedIbcClients []string `protobuf:"bytes,3,rep,name=paused_ibc_clients,json=pausedIbcClients,proto3" json:"paused_ibc_clients,omitempty"` } func (m *Params) Reset() { *m = Params{} } @@ -83,6 +103,13 @@ func (m *Params) GetIssuancePaused() bool { return false } +func (m *Params) GetPausedIbcClients() []string { + if m != nil { + return m.PausedIbcClients + } + return nil +} + func init() { proto.RegisterType((*Params)(nil), "cbdc.Params") } @@ -90,21 +117,23 @@ func init() { func init() { proto.RegisterFile("cbdc/params.proto", fileDescriptor_a383b166b3425dd0) } var fileDescriptor_a383b166b3425dd0 = []byte{ - // 212 bytes of a gzipped FileDescriptorProto + // 247 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x4c, 0x4e, 0x4a, 0x49, 0xd6, 0x2f, 0x48, 0x2c, 0x4a, 0xcc, 0x2d, 0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x01, 0x09, 0x49, 0x49, 0x26, 0xe7, 0x17, 0xe7, 0xe6, 0x17, 0xc7, 0x83, 0xc5, 0xf4, 0x21, 0x1c, 0x88, - 0x02, 0xa5, 0x44, 0x2e, 0xb6, 0x00, 0xb0, 0x06, 0x21, 0x3d, 0x2e, 0xd6, 0xfc, 0xf2, 0xbc, 0xd4, - 0x22, 0x09, 0x46, 0x05, 0x46, 0x0d, 0x4e, 0x27, 0x89, 0x4b, 0x5b, 0x74, 0x45, 0xa0, 0x4a, 0x1d, - 0x53, 0x52, 0x8a, 0x52, 0x8b, 0x8b, 0x83, 0x4b, 0x8a, 0x32, 0xf3, 0xd2, 0x83, 0x20, 0xca, 0x84, - 0xd4, 0xb9, 0xf8, 0x33, 0x8b, 0x8b, 0x4b, 0x13, 0xf3, 0x92, 0x53, 0xe3, 0x0b, 0x12, 0x4b, 0x8b, - 0x53, 0x53, 0x24, 0x98, 0x14, 0x18, 0x35, 0x38, 0x82, 0xf8, 0x60, 0xc2, 0x01, 0x60, 0x51, 0x27, - 0x97, 0x13, 0x8f, 0xe4, 0x18, 0x2f, 0x3c, 0x92, 0x63, 0x7c, 0xf0, 0x48, 0x8e, 0x71, 0xc2, 0x63, - 0x39, 0x86, 0x0b, 0x8f, 0xe5, 0x18, 0x6e, 0x3c, 0x96, 0x63, 0x88, 0xd2, 0x4a, 0xcf, 0x2c, 0xc9, - 0x28, 0x4d, 0xd2, 0x4b, 0xce, 0xcf, 0xd5, 0x2f, 0x48, 0x4d, 0x2d, 0x2a, 0xae, 0x2c, 0x2e, 0xd1, - 0x07, 0xb9, 0x58, 0x37, 0x2f, 0x3f, 0x25, 0x55, 0xbf, 0x02, 0xcc, 0xd6, 0x2f, 0xa9, 0x2c, 0x48, - 0x2d, 0x4e, 0x62, 0x03, 0xbb, 0xd7, 0x18, 0x10, 0x00, 0x00, 0xff, 0xff, 0x0b, 0x63, 0x99, 0x78, - 0xe5, 0x00, 0x00, 0x00, + 0x02, 0xa5, 0x7e, 0x46, 0x2e, 0xb6, 0x00, 0xb0, 0x0e, 0x21, 0x3d, 0x2e, 0xd6, 0xfc, 0xf2, 0xbc, + 0xd4, 0x22, 0x09, 0x46, 0x05, 0x46, 0x0d, 0x4e, 0x27, 0x89, 0x4b, 0x5b, 0x74, 0x45, 0xa0, 0x6a, + 0x1d, 0x53, 0x52, 0x8a, 0x52, 0x8b, 0x8b, 0x83, 0x4b, 0x8a, 0x32, 0xf3, 0xd2, 0x83, 0x20, 0xca, + 0x84, 0xd4, 0xb9, 0xf8, 0x33, 0x8b, 0x8b, 0x4b, 0x13, 0xf3, 0x92, 0x53, 0xe3, 0x0b, 0x12, 0x4b, + 0x8b, 0x53, 0x53, 0x24, 0x98, 0x14, 0x18, 0x35, 0x38, 0x82, 0xf8, 0x60, 0xc2, 0x01, 0x60, 0x51, + 0x21, 0x1d, 0x2e, 0x21, 0x88, 0x7c, 0x7c, 0x66, 0x52, 0x72, 0x7c, 0x72, 0x4e, 0x66, 0x6a, 0x5e, + 0x49, 0xb1, 0x04, 0xb3, 0x02, 0xb3, 0x06, 0x67, 0x90, 0x00, 0x44, 0xc6, 0x33, 0x29, 0xd9, 0x19, + 0x22, 0xee, 0xe4, 0x72, 0xe2, 0x91, 0x1c, 0xe3, 0x85, 0x47, 0x72, 0x8c, 0x0f, 0x1e, 0xc9, 0x31, + 0x4e, 0x78, 0x2c, 0xc7, 0x70, 0xe1, 0xb1, 0x1c, 0xc3, 0x8d, 0xc7, 0x72, 0x0c, 0x51, 0x5a, 0xe9, + 0x99, 0x25, 0x19, 0xa5, 0x49, 0x7a, 0xc9, 0xf9, 0xb9, 0xfa, 0x05, 0xa9, 0xa9, 0x45, 0xc5, 0x95, + 0xc5, 0x25, 0xfa, 0x20, 0x0f, 0xea, 0xe6, 0xe5, 0xa7, 0xa4, 0xea, 0x57, 0x80, 0xd9, 0xfa, 0x25, + 0x95, 0x05, 0xa9, 0xc5, 0x49, 0x6c, 0x60, 0xef, 0x19, 0x03, 0x02, 0x00, 0x00, 0xff, 0xff, 0xcf, + 0xa7, 0x1a, 0x43, 0x14, 0x01, 0x00, 0x00, } func (m *Params) Marshal() (dAtA []byte, err error) { @@ -127,6 +156,15 @@ func (m *Params) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.PausedIbcClients) > 0 { + for iNdEx := len(m.PausedIbcClients) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.PausedIbcClients[iNdEx]) + copy(dAtA[i:], m.PausedIbcClients[iNdEx]) + i = encodeVarintParams(dAtA, i, uint64(len(m.PausedIbcClients[iNdEx]))) + i-- + dAtA[i] = 0x1a + } + } if m.IssuancePaused { i-- if m.IssuancePaused { @@ -171,6 +209,12 @@ func (m *Params) Size() (n int) { if m.IssuancePaused { n += 2 } + if len(m.PausedIbcClients) > 0 { + for _, s := range m.PausedIbcClients { + l = len(s) + n += 1 + l + sovParams(uint64(l)) + } + } return n } @@ -261,6 +305,38 @@ func (m *Params) Unmarshal(dAtA []byte) error { } } m.IssuancePaused = bool(v != 0) + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field PausedIbcClients", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowParams + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthParams + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthParams + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.PausedIbcClients = append(m.PausedIbcClients, string(dAtA[iNdEx:postIndex])) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipParams(dAtA[iNdEx:]) diff --git a/x/cbdc/types/params_test.go b/x/cbdc/types/params_test.go index 07bb9b5a..bd166316 100644 --- a/x/cbdc/types/params_test.go +++ b/x/cbdc/types/params_test.go @@ -32,3 +32,40 @@ func TestParams_Validate(t *testing.T) { }) } } + +// The pause list is what governance edits during an incident, so a proposal that +// reads as pausing something must actually do it. Empty and duplicate entries are +// rejected because both would make a proposal misleading. +func TestValidatePausedIBCClients(t *testing.T) { + valid := func(clients ...string) *Params { + p := NewParams(sample.AccAddress(), false, clients...) + return &p + } + + if err := valid().Validate(); err != nil { + t.Errorf("an empty pause list is the default and must be valid: %v", err) + } + if err := valid("qbft-0", "qbft-1").Validate(); err != nil { + t.Errorf("distinct client ids must be valid: %v", err) + } + if err := valid("qbft-0", "").Validate(); err == nil { + t.Error("an empty client id must be rejected") + } + if err := valid("qbft-0", "qbft-0").Validate(); err == nil { + t.Error("a duplicate client id must be rejected") + } +} + +func TestIsIBCClientPaused(t *testing.T) { + p := NewParams(sample.AccAddress(), false, "qbft-0") + + if !p.IsIBCClientPaused("qbft-0") { + t.Error("a listed client must report paused") + } + if p.IsIBCClientPaused("qbft-1") { + t.Error("an unlisted client must not be paused — a pause is per corridor") + } + if DefaultParams().IsIBCClientPaused("qbft-0") { + t.Error("nothing may be paused by default") + } +} From 6f14919f8e56352ae92c81224610971649a09c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 08:30:42 +0200 Subject: [PATCH 05/61] feat(qbftclient): light client for Besu/QBFT counterparties A native in-tree light client, so the corridor's trust statement is cryptographic verification of the counterparty's own consensus rather than a quorum of attestors. exported.LightClientModule is a plain interface and Router.AddRoute accepts any implementation, so this needs no ibc-go change and no move to v11. Layered so the audited part survives a dependency bump: x/qbftclient/types holds the verification core and does not import ibc-go; x/qbftclient is a thin adapter implementing the thirteen LightClientModule methods. - Validator set is followed through the header chain rather than pinned at creation, so a rotation is a client implementation detail rather than a client migration (which would mean a new escrow address and voucher denom). - Misbehaviour freeze ships now: without it the client accepts whichever fork it is shown first. - RecoverClient is implemented, not stubbed. A freeze with no recovery is terminal, and it checks the substitute describes the same counterparty -- same chain id, same IBC contract, greater height -- so recovery cannot silently repoint a corridor while keeping the escrow address. Two findings pinned by tests: - Quorum is ceil(2n/3), taken from Besu's BftHelpers rather than inferred. - Storage values come back RLP-trimmed, so a 32-byte commitment returns shorter whenever it has leading zeroes. VerifyCommitment left-pads internally; a raw comparison would mismatch about one time in 256, and only in production. Heights are plain uint64 (QBFT has no revision concept) and Header carries RLP blobs rather than decomposed fields, because the block hash is taken over that encoding and re-serialising from parsed fields risks a digest that diverges from Besu's. Co-Authored-By: Claude Opus 5 (1M context) --- app/app.go | 14 + proto/qbftclient/qbftclient.proto | 97 ++ tests/integration/qbftclient_test.go | 224 ++++ x/qbftclient/codec.go | 37 + x/qbftclient/light_client_module.go | 380 +++++++ x/qbftclient/store.go | 47 + x/qbftclient/testutil/fixtures.go | 204 ++++ x/qbftclient/types/client_message.go | 106 ++ x/qbftclient/types/extradata.go | 113 ++ x/qbftclient/types/hashing.go | 91 ++ x/qbftclient/types/path.go | 98 ++ x/qbftclient/types/path_test.go | 95 ++ x/qbftclient/types/proof.go | 164 +++ x/qbftclient/types/proof_test.go | 181 ++++ x/qbftclient/types/qbftclient.pb.go | 1455 ++++++++++++++++++++++++++ x/qbftclient/types/state.go | 133 +++ x/qbftclient/types/state_test.go | 159 +++ x/qbftclient/types/update.go | 142 +++ x/qbftclient/types/update_test.go | 238 +++++ x/qbftclient/types/verify.go | 85 ++ x/qbftclient/types/verify_test.go | 160 +++ 21 files changed, 4223 insertions(+) create mode 100644 proto/qbftclient/qbftclient.proto create mode 100644 tests/integration/qbftclient_test.go create mode 100644 x/qbftclient/codec.go create mode 100644 x/qbftclient/light_client_module.go create mode 100644 x/qbftclient/store.go create mode 100644 x/qbftclient/testutil/fixtures.go create mode 100644 x/qbftclient/types/client_message.go create mode 100644 x/qbftclient/types/extradata.go create mode 100644 x/qbftclient/types/hashing.go create mode 100644 x/qbftclient/types/path.go create mode 100644 x/qbftclient/types/path_test.go create mode 100644 x/qbftclient/types/proof.go create mode 100644 x/qbftclient/types/proof_test.go create mode 100644 x/qbftclient/types/qbftclient.pb.go create mode 100644 x/qbftclient/types/state.go create mode 100644 x/qbftclient/types/state_test.go create mode 100644 x/qbftclient/types/update.go create mode 100644 x/qbftclient/types/update_test.go create mode 100644 x/qbftclient/types/verify.go create mode 100644 x/qbftclient/types/verify_test.go diff --git a/app/app.go b/app/app.go index a52f75d3..b59490e0 100644 --- a/app/app.go +++ b/app/app.go @@ -136,6 +136,8 @@ 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" @@ -687,6 +689,12 @@ func New( tmLightClientModule := ibctm.NewLightClientModule(appCodec, storeProvider) clientKeeper.AddRoute(ibctm.ModuleName, &tmLightClientModule) + // Light client of a Besu/QBFT counterparty. AddRoute takes any + // exported.LightClientModule, so this needs no ibc-go change; AllowedClients is + // left at its wildcard default, so no param update is required either. + qbftLightClientModule := qbftclient.NewLightClientModule(appCodec, storeProvider) + clientKeeper.AddRoute(qbfttypes.ClientType, &qbftLightClientModule) + /**** Module Hooks ****/ // register hooks after all modules have been initialized @@ -758,6 +766,12 @@ func New( app.BasicModuleManager.RegisterLegacyAminoCodec(cdc) app.BasicModuleManager.RegisterInterfaces(interfaceRegistry) + // The QBFT light client has no AppModule of its own — it owns no state outside + // the client store and no genesis — so its client state, consensus state and + // client messages are registered directly. Without this the codec cannot + // unmarshal them into their ibc-go interface types. + qbftclient.RegisterInterfaces(interfaceRegistry) + // NOTE: upgrade module is required to be prioritized app.mm.SetOrderPreBlockers( upgradetypes.ModuleName, diff --git a/proto/qbftclient/qbftclient.proto b/proto/qbftclient/qbftclient.proto new file mode 100644 index 00000000..72dd5a60 --- /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/tests/integration/qbftclient_test.go b/tests/integration/qbftclient_test.go new file mode 100644 index 00000000..86d40d50 --- /dev/null +++ b/tests/integration/qbftclient_test.go @@ -0,0 +1,224 @@ +package integration + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + commitmenttypesv2 "github.com/cosmos/ibc-go/v10/modules/core/23-commitment/types/v2" + "github.com/cosmos/ibc-go/v10/modules/core/exported" + + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + qbfttypes "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +const ( + qbftChainID = 1338 + qbftTrustingPeriod = uint64(14 * 24 * 60 * 60) // DEC-8 + qbftClockDrift = uint64(10) +) + +var qbftContract = common.HexToAddress("0x00000000000000000000000000000000cafebabe") + +// TestQBFTClient_Lifecycle drives create → update → membership → misbehaviour → +// recover through the real ClientKeeper. +// +// The unit tests prove the verification logic; this proves the adapter: that the +// protos round-trip through the app codec, that the client route resolves, and that +// the store keys line up. +func TestQBFTClient_Lifecycle(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 1) + chain := chains[0] + a := chain.App.(*app.App) + cdc := a.AppCodec() + k := a.GetIBCKeeper().ClientKeeper + + keys := qbfttestutil.Keys(t, 4) + + // A packet commitment sitting in the counterparty's IBC contract, at the slot + // solidity-ibc-eureka would use. + const counterpartyClientID = "qbft-0" + const sequence = 1 + commitment := common.HexToHash("0x00bbccddeeff00112233445566778899aabbccddeeff00112233445566778899") + slot := qbfttypes.PacketCommitmentSlot(counterpartyClientID, sequence) + state := qbfttestutil.NewState(t, qbftContract, map[common.Hash][]byte{slot: commitment.Bytes()}) + + ctx := chain.GetContext() + now := uint64(ctx.BlockTime().Unix()) + + genesis := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: 100, Time: now - 4, Seals: 3, StateRoot: state.Root, + }) + genesisConsensus, err := qbfttypes.NewConsensusState(genesis) + require.NoError(t, err) + + clientState := &qbfttypes.ClientState{ + ChainId: qbftChainID, + TrustingPeriod: qbftTrustingPeriod, + MaxClockDrift: qbftClockDrift, + LatestHeight: 100, + IbcContractAddress: qbftContract.Bytes(), + } + + clientStateBz, err := cdc.Marshal(clientState) + require.NoError(t, err) + consensusStateBz, err := cdc.Marshal(genesisConsensus) + require.NoError(t, err) + + clientID, err := k.CreateClient(ctx, qbfttypes.ClientType, clientStateBz, consensusStateBz) + require.NoError(t, err, "creating a QBFT client must succeed") + require.Equal(t, "qbft-0", clientID, "client id is -") + + // The route registered in app.go must resolve, and the client must be usable. + require.Equal(t, exported.Active, k.GetClientStatus(ctx, clientID)) + + t.Run("membership proof at the genesis height", func(t *testing.T) { + proof, err := cdc.Marshal(&qbfttypes.StorageProof{ + AccountProof: state.AccountProof, + StorageProof: state.StorageProof(t, slot), + }) + require.NoError(t, err) + + path := commitmenttypesv2.NewMerklePath(qbfttypes.PacketCommitmentPath(counterpartyClientID, sequence)) + + require.NoError(t, + k.VerifyMembership(ctx, clientID, clienttypes.NewHeight(0, 100), 0, 0, proof, path, commitment.Bytes()), + "the commitment must verify against the genesis state root") + + // The same proof must not verify a different value. + require.Error(t, + k.VerifyMembership(ctx, clientID, clienttypes.NewHeight(0, 100), 0, 0, proof, path, common.HexToHash("0x01").Bytes()), + "a wrong value must not verify") + }) + + t.Run("non-membership at an unused sequence", func(t *testing.T) { + absentSlot := qbfttypes.PacketCommitmentSlot(counterpartyClientID, 99) + proof, err := cdc.Marshal(&qbfttypes.StorageProof{ + AccountProof: state.AccountProof, + StorageProof: state.StorageProof(t, absentSlot), + }) + require.NoError(t, err) + + path := commitmenttypesv2.NewMerklePath(qbfttypes.PacketCommitmentPath(counterpartyClientID, 99)) + + require.NoError(t, + k.VerifyNonMembership(ctx, clientID, clienttypes.NewHeight(0, 100), 0, 0, proof, path), + "an unwritten slot must prove absent") + }) + + t.Run("update advances the client", func(t *testing.T) { + next := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: 101, Time: now - 2, Seals: 3, StateRoot: state.Root, + }) + msg := &qbfttypes.Header{RlpHeader: qbfttestutil.RLP(t, next)} + + require.NoError(t, k.UpdateClient(ctx, clientID, msg), "a validly sealed header must be accepted") + + require.Equal(t, clienttypes.NewHeight(0, 101), k.GetClientLatestHeight(ctx, clientID), + "the client must advance to the new height") + }) + + t.Run("a header sealed by strangers is rejected", func(t *testing.T) { + strangers := qbfttestutil.Keys(t, 4) + bad := qbfttestutil.SealedHeader(t, strangers, qbfttestutil.HeaderOpts{ + Height: 102, Time: now - 1, Seals: 3, StateRoot: state.Root, + }) + msg := &qbfttypes.Header{RlpHeader: qbfttestutil.RLP(t, bad)} + + require.Error(t, k.UpdateClient(ctx, clientID, msg), + "a header from outside the validator set must not update the client") + }) + + t.Run("misbehaviour freezes the client", func(t *testing.T) { + // Two headers at one height, both properly sealed, different block times — + // so different block hashes. QBFT is instantly final, so this is equivocation. + h1 := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: 102, Time: now, Seals: 3, StateRoot: state.Root, + }) + h2 := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: 102, Time: now + 1, Seals: 3, StateRoot: state.Root, + }) + + msg := &qbfttypes.Misbehaviour{ + Header_1: qbfttestutil.RLP(t, h1), + Header_2: qbfttestutil.RLP(t, h2), + } + + require.NoError(t, k.UpdateClient(ctx, clientID, msg), "valid misbehaviour must be accepted") + require.Equal(t, exported.Frozen, k.GetClientStatus(ctx, clientID), + "equivocation must freeze the client") + }) + + t.Run("a frozen client refuses proofs", func(t *testing.T) { + proof, err := cdc.Marshal(&qbfttypes.StorageProof{ + AccountProof: state.AccountProof, + StorageProof: state.StorageProof(t, slot), + }) + require.NoError(t, err) + + path := commitmenttypesv2.NewMerklePath(qbfttypes.PacketCommitmentPath(counterpartyClientID, sequence)) + + require.Error(t, + k.VerifyMembership(ctx, clientID, clienttypes.NewHeight(0, 100), 0, 0, proof, path, commitment.Bytes()), + "a frozen client must not serve membership proofs") + }) +} + +// TestQBFTClient_RejectsMismatchedSubstitute pins the check that makes recovery +// safe: a substitute describing a different counterparty must be refused, or +// governance recovery could repoint a live corridor at another chain while the +// escrow address and voucher denom stay the same. +func TestQBFTClient_RejectsMismatchedSubstitute(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 1) + chain := chains[0] + a := chain.App.(*app.App) + cdc := a.AppCodec() + k := a.GetIBCKeeper().ClientKeeper + ctx := chain.GetContext() + now := uint64(ctx.BlockTime().Unix()) + + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, qbftContract, map[common.Hash][]byte{}) + + create := func(chainID uint64, contract common.Address, height uint64) string { + t.Helper() + header := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: height, Time: now - 4, Seals: 3, StateRoot: state.Root, + }) + consensus, err := qbfttypes.NewConsensusState(header) + require.NoError(t, err) + + csBz, err := cdc.Marshal(&qbfttypes.ClientState{ + ChainId: chainID, + TrustingPeriod: qbftTrustingPeriod, + MaxClockDrift: qbftClockDrift, + LatestHeight: height, + IbcContractAddress: contract.Bytes(), + }) + require.NoError(t, err) + consBz, err := cdc.Marshal(consensus) + require.NoError(t, err) + + id, err := k.CreateClient(ctx, qbfttypes.ClientType, csBz, consBz) + require.NoError(t, err) + return id + } + + subject := create(qbftChainID, qbftContract, 100) + otherChain := create(9999, qbftContract, 200) + otherContract := create(qbftChainID, common.HexToAddress("0x00000000000000000000000000000000deadbeef"), 200) + + require.Error(t, k.RecoverClient(ctx, subject, otherChain), + "a substitute on a different chain must be refused") + require.Error(t, k.RecoverClient(ctx, subject, otherContract), + "a substitute pointing at a different IBC contract must be refused") +} diff --git a/x/qbftclient/codec.go b/x/qbftclient/codec.go new file mode 100644 index 00000000..ce3d016d --- /dev/null +++ b/x/qbftclient/codec.go @@ -0,0 +1,37 @@ +// Package qbftclient adapts the Besu/QBFT verification core in +// x/qbftclient/types to ibc-go's light client interface. +// +// The split is deliberate: everything that decides whether a header or a proof is +// valid lives in the core and imports no ibc-go, so an ibc-go interface change +// rewrites this package and leaves the audited consensus logic untouched. +package qbftclient + +import ( + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + + "github.com/cosmos/ibc-go/v10/modules/core/exported" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// RegisterInterfaces registers the QBFT client's concrete implementations of the +// ibc-go client interfaces. Without it the codec cannot unmarshal a client state +// or a client message into its interface type. +func RegisterInterfaces(registry codectypes.InterfaceRegistry) { + registry.RegisterImplementations( + (*exported.ClientState)(nil), + &types.ClientState{}, + ) + registry.RegisterImplementations( + (*exported.ConsensusState)(nil), + &types.ConsensusState{}, + ) + registry.RegisterImplementations( + (*exported.ClientMessage)(nil), + &types.Header{}, + ) + registry.RegisterImplementations( + (*exported.ClientMessage)(nil), + &types.Misbehaviour{}, + ) +} diff --git a/x/qbftclient/light_client_module.go b/x/qbftclient/light_client_module.go new file mode 100644 index 00000000..759f2e92 --- /dev/null +++ b/x/qbftclient/light_client_module.go @@ -0,0 +1,380 @@ +package qbftclient + +import ( + "bytes" + "errors" + "fmt" + + errorsmod "cosmossdk.io/errors" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + commitmenttypesv2 "github.com/cosmos/ibc-go/v10/modules/core/23-commitment/types/v2" + ibcerrors "github.com/cosmos/ibc-go/v10/modules/core/errors" + "github.com/cosmos/ibc-go/v10/modules/core/exported" + + "github.com/ethereum/go-ethereum/common" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +var _ exported.LightClientModule = (*LightClientModule)(nil) + +// LightClientModule is the ibc-go light client for a Besu/QBFT counterparty. +type LightClientModule struct { + cdc codec.BinaryCodec + storeProvider clienttypes.StoreProvider +} + +// NewLightClientModule returns a QBFT LightClientModule. +func NewLightClientModule(cdc codec.BinaryCodec, storeProvider clienttypes.StoreProvider) LightClientModule { + return LightClientModule{cdc: cdc, storeProvider: storeProvider} +} + +// Initialize validates and stores the genesis client and consensus states. +func (l LightClientModule) Initialize(ctx sdk.Context, clientID string, clientStateBz, consensusStateBz []byte) error { + var clientState types.ClientState + if err := l.cdc.Unmarshal(clientStateBz, &clientState); err != nil { + return fmt.Errorf("qbft: unmarshalling client state: %w", err) + } + if err := clientState.Validate(); err != nil { + return err + } + + var consensusState types.ConsensusState + if err := l.cdc.Unmarshal(consensusStateBz, &consensusState); err != nil { + return fmt.Errorf("qbft: unmarshalling consensus state: %w", err) + } + if err := consensusState.ValidateBasic(); err != nil { + return err + } + + store := l.storeProvider.ClientStore(ctx, clientID) + setClientState(store, l.cdc, &clientState) + setConsensusState(store, l.cdc, &consensusState, height(clientState.LatestHeight)) + return nil +} + +// VerifyClientMessage checks a Header or Misbehaviour against the stored state. +// It only verifies; UpdateState and UpdateStateOnMisbehaviour do the writing. +func (l LightClientModule) VerifyClientMessage(ctx sdk.Context, clientID string, clientMsg exported.ClientMessage) error { + clientState, consensusState, err := l.load(ctx, clientID) + if err != nil { + return err + } + + trusted := consensusState.Trusted(clientState.LatestHeight) + + switch msg := clientMsg.(type) { + case *types.Header: + eth, err := msg.EthHeader() + if err != nil { + return err + } + return types.VerifyHeader( + trusted, eth, ctx.BlockTime(), + clientState.TrustingPeriodDuration(), clientState.MaxClockDriftDuration(), + ) + + case *types.Misbehaviour: + h1, h2, err := msg.Headers() + if err != nil { + return err + } + if _, err := types.DetectMisbehaviour(trusted, h1, h2); err != nil { + return err + } + return nil + + default: + return errorsmod.Wrapf(clienttypes.ErrInvalidClientType, "expected %T or %T, got %T", + &types.Header{}, &types.Misbehaviour{}, clientMsg) + } +} + +// CheckForMisbehaviour reports whether the message is evidence of equivocation. +// VerifyClientMessage has already run, so a Misbehaviour reaching here is valid. +func (l LightClientModule) CheckForMisbehaviour(ctx sdk.Context, clientID string, clientMsg exported.ClientMessage) bool { + _, ok := clientMsg.(*types.Misbehaviour) + return ok +} + +// UpdateStateOnMisbehaviour freezes the client at its current height. +// +// Freezing is not the end of the corridor: RecoverClient restores it through +// governance. That pairing is deliberate — a freeze with no recovery would force a +// new client id, and with it a new escrow address and a new voucher denom. +func (l LightClientModule) UpdateStateOnMisbehaviour(ctx sdk.Context, clientID string, _ exported.ClientMessage) { + store := l.storeProvider.ClientStore(ctx, clientID) + clientState, found := getClientState(store, l.cdc) + if !found { + panic(errorsmod.Wrap(clienttypes.ErrClientNotFound, clientID)) + } + + clientState.FrozenHeight = clientState.LatestHeight + setClientState(store, l.cdc, clientState) +} + +// UpdateState stores the verified header and advances the client. +// +// The validator set advances here and only here: the set the verified header +// carries becomes the set trusted for the next one. +func (l LightClientModule) UpdateState(ctx sdk.Context, clientID string, clientMsg exported.ClientMessage) []exported.Height { + header, ok := clientMsg.(*types.Header) + if !ok { + panic(errorsmod.Wrapf(clienttypes.ErrInvalidClientType, "expected %T, got %T", &types.Header{}, clientMsg)) + } + + eth, err := header.EthHeader() + if err != nil { + panic(err) + } + consensusState, err := types.NewConsensusState(eth) + if err != nil { + panic(err) + } + + store := l.storeProvider.ClientStore(ctx, clientID) + clientState, found := getClientState(store, l.cdc) + if !found { + panic(errorsmod.Wrap(clienttypes.ErrClientNotFound, clientID)) + } + + newHeight := eth.Number.Uint64() + clientState.LatestHeight = newHeight + setClientState(store, l.cdc, clientState) + setConsensusState(store, l.cdc, consensusState, height(newHeight)) + + return []exported.Height{height(newHeight)} +} + +// VerifyMembership proves that path holds value in the counterparty's IBC contract +// at the given height. +// +// The ICS-24 path is used exactly as handed over rather than rebuilt from its +// parts: ibc-go and solidity-ibc-eureka derive byte-identical paths +// (24-host/v2/packet_keys.go and contracts/utils/ICS24Host.sol both produce +// clientID || kind || be64(sequence)), so re-deriving could only introduce a +// divergence. +func (l LightClientModule) VerifyMembership( + ctx sdk.Context, + clientID string, + h exported.Height, + _, _ uint64, + proof []byte, + path exported.Path, + value []byte, +) error { + clientState, consensusState, err := l.loadAt(ctx, clientID, h) + if err != nil { + return err + } + + key, err := merklePathKey(path) + if err != nil { + return err + } + storageProof, err := l.unmarshalProof(proof) + if err != nil { + return err + } + + return types.VerifyCommitment( + consensusState.Root(), + clientState.ContractAddress(), + types.CommitmentSlot(key), + common.BytesToHash(value), + storageProof.AccountProof, + storageProof.StorageProof, + ) +} + +// VerifyNonMembership proves that path holds nothing at the given height. +func (l LightClientModule) VerifyNonMembership( + ctx sdk.Context, + clientID string, + h exported.Height, + _, _ uint64, + proof []byte, + path exported.Path, +) error { + clientState, consensusState, err := l.loadAt(ctx, clientID, h) + if err != nil { + return err + } + + key, err := merklePathKey(path) + if err != nil { + return err + } + storageProof, err := l.unmarshalProof(proof) + if err != nil { + return err + } + + return types.VerifyStorageAbsent( + consensusState.Root(), + clientState.ContractAddress(), + types.CommitmentSlot(key), + storageProof.AccountProof, + storageProof.StorageProof, + ) +} + +// Status reports whether the client may still process packets. +// +// Expiry is measured against the latest consensus state's timestamp, which is why +// a quiet corridor eventually stops: nothing refreshes it but a client update. +func (l LightClientModule) Status(ctx sdk.Context, clientID string) exported.Status { + clientState, consensusState, err := l.load(ctx, clientID) + if err != nil { + return exported.Unknown + } + if clientState.IsFrozen() { + return exported.Frozen + } + if ctx.BlockTime().Sub(consensusState.Trusted(0).Timestamp) >= clientState.TrustingPeriodDuration() { + return exported.Expired + } + return exported.Active +} + +// LatestHeight returns the highest verified block, or a zero height if the client +// is absent — the interface requires a value rather than an error here. +func (l LightClientModule) LatestHeight(ctx sdk.Context, clientID string) exported.Height { + store := l.storeProvider.ClientStore(ctx, clientID) + clientState, found := getClientState(store, l.cdc) + if !found { + return clienttypes.ZeroHeight() + } + return height(clientState.LatestHeight) +} + +// TimestampAtHeight returns the stored block time, in nanoseconds. +func (l LightClientModule) TimestampAtHeight(ctx sdk.Context, clientID string, h exported.Height) (uint64, error) { + store := l.storeProvider.ClientStore(ctx, clientID) + consensusState, found := getConsensusState(store, l.cdc, h) + if !found { + return 0, errorsmod.Wrapf(clienttypes.ErrConsensusStateNotFound, "height %s", h) + } + return consensusState.Timestamp, nil +} + +// RecoverClient replaces a frozen or expired client's state with a live +// substitute's, under governance. +// +// This is implemented rather than stubbed on purpose. Without it a freeze would be +// terminal: recovery would mean a new client id, and the client id is baked into +// the escrow address and the voucher denom on both sides, so every holder of a +// voucher would be stranded behind a dead client. +func (l LightClientModule) RecoverClient(ctx sdk.Context, clientID, substituteClientID string) error { + substituteStore := l.storeProvider.ClientStore(ctx, substituteClientID) + substitute, found := getClientState(substituteStore, l.cdc) + if !found { + return errorsmod.Wrap(clienttypes.ErrClientNotFound, substituteClientID) + } + + subjectStore := l.storeProvider.ClientStore(ctx, clientID) + subject, found := getClientState(subjectStore, l.cdc) + if !found { + return errorsmod.Wrap(clienttypes.ErrClientNotFound, clientID) + } + + // The substitute must describe the same counterparty, or recovery would + // silently repoint the corridor at another chain or another contract. + if subject.ChainId != substitute.ChainId { + return errorsmod.Wrapf(clienttypes.ErrInvalidClient, + "substitute chain id %d does not match subject %d", substitute.ChainId, subject.ChainId) + } + if !bytes.Equal(subject.IbcContractAddress, substitute.IbcContractAddress) { + return errorsmod.Wrap(clienttypes.ErrInvalidClient, + "substitute IBC contract address does not match subject") + } + if substitute.LatestHeight <= subject.LatestHeight { + return errorsmod.Wrapf(clienttypes.ErrInvalidHeight, + "substitute height %d is not above subject height %d", substitute.LatestHeight, subject.LatestHeight) + } + + substituteConsensus, found := getConsensusState(substituteStore, l.cdc, height(substitute.LatestHeight)) + if !found { + return errorsmod.Wrapf(clienttypes.ErrConsensusStateNotFound, + "substitute %s at height %d", substituteClientID, substitute.LatestHeight) + } + + subject.FrozenHeight = 0 + subject.LatestHeight = substitute.LatestHeight + setClientState(subjectStore, l.cdc, subject) + setConsensusState(subjectStore, l.cdc, substituteConsensus, height(substitute.LatestHeight)) + return nil +} + +// VerifyUpgradeAndUpdateState is not supported. +// +// The IBC upgrade path expects the counterparty to commit an upgraded client state +// to a known store path before halting. Besu has no such protocol, so there is +// nothing to prove; a counterparty upgrade is handled by creating a new client. +func (l LightClientModule) VerifyUpgradeAndUpdateState( + _ sdk.Context, _ string, _, _, _, _ []byte, +) error { + return errorsmod.Wrap(ibcerrors.ErrInvalidRequest, "qbft: client upgrades are not supported") +} + +// load returns the client state and the consensus state at its latest height. +func (l LightClientModule) load(ctx sdk.Context, clientID string) (*types.ClientState, *types.ConsensusState, error) { + store := l.storeProvider.ClientStore(ctx, clientID) + clientState, found := getClientState(store, l.cdc) + if !found { + return nil, nil, errorsmod.Wrap(clienttypes.ErrClientNotFound, clientID) + } + consensusState, found := getConsensusState(store, l.cdc, height(clientState.LatestHeight)) + if !found { + return nil, nil, errorsmod.Wrapf(clienttypes.ErrConsensusStateNotFound, + "%s at height %d", clientID, clientState.LatestHeight) + } + return clientState, consensusState, nil +} + +// loadAt returns the client state and the consensus state at a specific height, +// refusing to serve a frozen client. +func (l LightClientModule) loadAt(ctx sdk.Context, clientID string, h exported.Height) (*types.ClientState, *types.ConsensusState, error) { + store := l.storeProvider.ClientStore(ctx, clientID) + clientState, found := getClientState(store, l.cdc) + if !found { + return nil, nil, errorsmod.Wrap(clienttypes.ErrClientNotFound, clientID) + } + if clientState.IsFrozen() { + return nil, nil, errorsmod.Wrapf(clienttypes.ErrClientFrozen, "%s at height %d", clientID, clientState.FrozenHeight) + } + consensusState, found := getConsensusState(store, l.cdc, h) + if !found { + return nil, nil, errorsmod.Wrapf(clienttypes.ErrConsensusStateNotFound, "%s at height %s", clientID, h) + } + return clientState, consensusState, nil +} + +func (l LightClientModule) unmarshalProof(bz []byte) (*types.StorageProof, error) { + var proof types.StorageProof + if err := l.cdc.Unmarshal(bz, &proof); err != nil { + return nil, fmt.Errorf("qbft: unmarshalling storage proof: %w", err) + } + if err := proof.Validate(); err != nil { + return nil, err + } + return &proof, nil +} + +// merklePathKey extracts the ICS-24 key from a merkle path. Only the final element +// is meaningful here: an EVM counterparty has no store prefix to walk, because the +// contract address already identifies where the commitments live. +func merklePathKey(path exported.Path) ([]byte, error) { + merklePath, ok := path.(commitmenttypesv2.MerklePath) + if !ok { + return nil, errorsmod.Wrapf(ibcerrors.ErrInvalidType, "expected %T, got %T", commitmenttypesv2.MerklePath{}, path) + } + if len(merklePath.KeyPath) == 0 { + return nil, errors.New("qbft: merkle path is empty") + } + return merklePath.KeyPath[len(merklePath.KeyPath)-1], nil +} diff --git a/x/qbftclient/store.go b/x/qbftclient/store.go new file mode 100644 index 00000000..e214313a --- /dev/null +++ b/x/qbftclient/store.go @@ -0,0 +1,47 @@ +package qbftclient + +import ( + storetypes "cosmossdk.io/store/types" + + "github.com/cosmos/cosmos-sdk/codec" + + clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" + host "github.com/cosmos/ibc-go/v10/modules/core/24-host" + "github.com/cosmos/ibc-go/v10/modules/core/exported" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// height wraps a QBFT block number in the Height shape ibc-go expects. +// +// The revision number is always zero: QBFT has no chain-restart protocol that +// would bump one, so there is no second dimension to carry. +func height(blockNumber uint64) clienttypes.Height { + return clienttypes.NewHeight(0, blockNumber) +} + +func setClientState(store storetypes.KVStore, cdc codec.BinaryCodec, cs *types.ClientState) { + store.Set(host.ClientStateKey(), clienttypes.MustMarshalClientState(cdc, cs)) +} + +func getClientState(store storetypes.KVStore, cdc codec.BinaryCodec) (*types.ClientState, bool) { + bz := store.Get(host.ClientStateKey()) + if len(bz) == 0 { + return nil, false + } + cs, ok := clienttypes.MustUnmarshalClientState(cdc, bz).(*types.ClientState) + return cs, ok +} + +func setConsensusState(store storetypes.KVStore, cdc codec.BinaryCodec, cs *types.ConsensusState, h exported.Height) { + store.Set(host.ConsensusStateKey(h), clienttypes.MustMarshalConsensusState(cdc, cs)) +} + +func getConsensusState(store storetypes.KVStore, cdc codec.BinaryCodec, h exported.Height) (*types.ConsensusState, bool) { + bz := store.Get(host.ConsensusStateKey(h)) + if len(bz) == 0 { + return nil, false + } + cs, ok := clienttypes.MustUnmarshalConsensusState(cdc, bz).(*types.ConsensusState) + return cs, ok +} diff --git a/x/qbftclient/testutil/fixtures.go b/x/qbftclient/testutil/fixtures.go new file mode 100644 index 00000000..dd8fdd21 --- /dev/null +++ b/x/qbftclient/testutil/fixtures.go @@ -0,0 +1,204 @@ +// Package testutil builds synthetic Besu/QBFT material — sealed headers and +// Merkle-Patricia state proofs — for tests of the light client and, later, of the +// proof constructor that feeds it. +// +// Both sides of the corridor have to agree on these encodings byte for byte, so +// they are produced in one place rather than rebuilt per test. +package testutil + +import ( + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie" + "github.com/ethereum/go-ethereum/triedb" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// Keys generates n validator keys. +func Keys(t *testing.T, n int) []*ecdsa.PrivateKey { + t.Helper() + keys := make([]*ecdsa.PrivateKey, n) + for i := range keys { + k, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + keys[i] = k + } + return keys +} + +// Validators returns the addresses behind keys, in order. +func Validators(keys []*ecdsa.PrivateKey) []common.Address { + out := make([]common.Address, len(keys)) + for i, k := range keys { + out[i] = crypto.PubkeyToAddress(k.PublicKey) + } + return out +} + +// HeaderOpts configures a synthetic header. +type HeaderOpts struct { + // Height is the block number. + Height uint64 + // Time is the block timestamp, in seconds. + Time uint64 + // Seals is how many of Signers seal the header. Defaults to all of them. + Seals int + // Carried, when set, is the validator set the header announces. Defaults to + // the signing set; setting it to something else makes the header a rotation + // block. + Carried []common.Address + // StateRoot is the block's state root, which membership proofs anchor to. + StateRoot common.Hash +} + +// SealedHeader builds a QBFT header sealed by signers. +func SealedHeader(t *testing.T, signers []*ecdsa.PrivateKey, opts HeaderOpts) *ethtypes.Header { + t.Helper() + + seals := opts.Seals + if seals == 0 { + seals = len(signers) + } + carried := opts.Carried + if carried == nil { + carried = Validators(signers) + } + + extra := &types.ExtraData{ + Vanity: make([]byte, types.ExtraVanityLength), + Validators: carried, + } + encoded, err := extra.Encode() + if err != nil { + t.Fatalf("encode extraData: %v", err) + } + + h := ðtypes.Header{ + Number: new(big.Int).SetUint64(opts.Height), + Difficulty: big.NewInt(1), + Time: opts.Time, + Root: opts.StateRoot, + Extra: encoded, + } + + // The commit-seal digest strips the seals, so it is stable across adding them. + digest, err := types.CommitSealHash(h) + if err != nil { + t.Fatalf("commit seal hash: %v", err) + } + for _, k := range signers[:seals] { + sig, err := crypto.Sign(digest.Bytes(), k) + if err != nil { + t.Fatalf("sign: %v", err) + } + extra.Seals = append(extra.Seals, sig) + } + + if h.Extra, err = extra.Encode(); err != nil { + t.Fatalf("re-encode extraData: %v", err) + } + return h +} + +// RLP encodes a header the way a Header client message carries it. +func RLP(t *testing.T, h *ethtypes.Header) []byte { + t.Helper() + bz, err := rlp.EncodeToBytes(h) + if err != nil { + t.Fatalf("encode header: %v", err) + } + return bz +} + +// proofList collects trie nodes in the shape eth_getProof returns them. +type proofList [][]byte + +func (p *proofList) Put(_, value []byte) error { + *p = append(*p, common.CopyBytes(value)) + return nil +} + +func (p *proofList) Delete(_ []byte) error { panic("proofList: Delete not supported") } + +// State is a synthetic two-level state: one contract account whose storage holds +// the given slots, with the proofs needed to verify any of them. +type State struct { + // Root is the state root to put in a header. + Root common.Hash + // Contract is the account holding the storage. + Contract common.Address + // AccountProof proves Contract against Root. + AccountProof [][]byte + + storage *trie.Trie +} + +// NewState builds a state trie containing one contract with the given storage. +func NewState(t *testing.T, contract common.Address, slots map[common.Hash][]byte) State { + t.Helper() + + storage := newTrie(t) + for slot, value := range slots { + // Mirror how a chain writes storage: trimmed, then RLP-encoded into the + // leaf (geth core/state/state_object.go). + encoded, err := rlp.EncodeToBytes(common.TrimLeftZeroes(value)) + if err != nil { + t.Fatalf("encode storage value: %v", err) + } + update(t, storage, crypto.Keccak256(slot.Bytes()), encoded) + } + + acc := ethtypes.NewEmptyStateAccount() + acc.Root = storage.Hash() + accBlob, err := rlp.EncodeToBytes(acc) + if err != nil { + t.Fatalf("encode account: %v", err) + } + + state := newTrie(t) + update(t, state, crypto.Keccak256(contract.Bytes()), accBlob) + + return State{ + Root: state.Hash(), + Contract: contract, + AccountProof: prove(t, state, crypto.Keccak256(contract.Bytes())), + storage: storage, + } +} + +// StorageProof returns the proof for one slot, present or absent. +func (s State) StorageProof(t *testing.T, slot common.Hash) [][]byte { + t.Helper() + return prove(t, s.storage, crypto.Keccak256(slot.Bytes())) +} + +func newTrie(t *testing.T) *trie.Trie { + t.Helper() + return trie.NewEmpty(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil)) +} + +func update(t *testing.T, tr *trie.Trie, key, value []byte) { + t.Helper() + if err := tr.Update(key, value); err != nil { + t.Fatalf("trie update: %v", err) + } +} + +func prove(t *testing.T, tr *trie.Trie, key []byte) [][]byte { + t.Helper() + var pl proofList + if err := tr.Prove(key, &pl); err != nil { + t.Fatalf("trie prove: %v", err) + } + return pl +} diff --git a/x/qbftclient/types/client_message.go b/x/qbftclient/types/client_message.go new file mode 100644 index 00000000..e5817392 --- /dev/null +++ b/x/qbftclient/types/client_message.go @@ -0,0 +1,106 @@ +package types + +import ( + "fmt" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rlp" +) + +// ClientType is the ibc-go client type identifier for this light client. Client +// ids are formed as "-", e.g. qbft-0. +const ClientType = "qbft" + +// ClientType implements the ibc-go exported.ClientState interface. +func (cs *ClientState) ClientType() string { return ClientType } + +// ClientType implements the ibc-go exported.ConsensusState interface. +func (cs *ConsensusState) ClientType() string { return ClientType } + +// GetTimestamp, required by the ibc-go exported.ConsensusState interface, is +// generated: the proto field is already named timestamp and already in +// nanoseconds, which is the unit that interface is defined in. + +// ClientType implements the ibc-go exported.ClientMessage interface. +func (h *Header) ClientType() string { return ClientType } + +// ClientType implements the ibc-go exported.ClientMessage interface. +func (m *Misbehaviour) ClientType() string { return ClientType } + +// EthHeader decodes the carried RLP into a block header. +// +// The RLP is kept rather than parsed fields precisely so this decode is the only +// interpretation step: the block hash is taken over these bytes, so anything that +// re-serialises risks a digest Besu would not recognise. +func (h *Header) EthHeader() (*ethtypes.Header, error) { + var out ethtypes.Header + if err := rlp.DecodeBytes(h.RlpHeader, &out); err != nil { + return nil, fmt.Errorf("qbft: decoding header RLP: %w", err) + } + return &out, nil +} + +// ValidateBasic implements the ibc-go exported.ClientMessage interface. It checks +// only what can be checked without state: that the header decodes, carries a +// height, and has a well-formed extraData. +func (h *Header) ValidateBasic() error { + eth, err := h.EthHeader() + if err != nil { + return err + } + if eth.Number == nil { + return ErrNoNumber + } + if _, err := DecodeExtraData(eth.Extra); err != nil { + return err + } + return nil +} + +// Headers decodes both carried headers. +func (m *Misbehaviour) Headers() (*ethtypes.Header, *ethtypes.Header, error) { + h1 := &Header{RlpHeader: m.Header_1} + h2 := &Header{RlpHeader: m.Header_2} + + eth1, err := h1.EthHeader() + if err != nil { + return nil, nil, fmt.Errorf("qbft: first misbehaviour header: %w", err) + } + eth2, err := h2.EthHeader() + if err != nil { + return nil, nil, fmt.Errorf("qbft: second misbehaviour header: %w", err) + } + return eth1, eth2, nil +} + +// ValidateBasic implements the ibc-go exported.ClientMessage interface. +// +// It stops at "both headers are well formed and claim the same height". Whether +// they are actually evidence of equivocation needs the trusted validator set, so +// that check lives in DetectMisbehaviour rather than here. +func (m *Misbehaviour) ValidateBasic() error { + h1, h2, err := m.Headers() + if err != nil { + return err + } + if h1.Number == nil || h2.Number == nil { + return ErrNoNumber + } + if h1.Number.Cmp(h2.Number) != 0 { + return fmt.Errorf("%w: %d and %d", ErrNotSameHeight, h1.Number, h2.Number) + } + return nil +} + +// Validate reports whether a storage proof is usable. +// +// Only the account proof is required. The storage half is legitimately empty when +// the contract's storage trie is empty: there are no nodes to walk, and every slot +// is absent by definition. That is the state a freshly deployed IBC contract is in, +// so rejecting it here would break the corridor's first timeout. +func (p *StorageProof) Validate() error { + if len(p.AccountProof) == 0 { + return fmt.Errorf("qbft: storage proof is missing its account proof") + } + return nil +} diff --git a/x/qbftclient/types/extradata.go b/x/qbftclient/types/extradata.go new file mode 100644 index 00000000..e1c13dac --- /dev/null +++ b/x/qbftclient/types/extradata.go @@ -0,0 +1,113 @@ +// Package types implements verification of Hyperledger Besu QBFT block headers, +// the consensus primitives an IBC light client of a Besu/QBFT chain is built on. +// +// The encodings here mirror Besu's QbftExtraDataCodec and BftBlockHashing exactly. +// Any divergence makes every header fail to verify, so the reference implementation +// is cited inline at each decision point. +package types + +import ( + "errors" + "fmt" + "io" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" +) + +// ExtraVanityLength is the fixed length of the vanity prefix that opens a QBFT +// extraData field (Besu: BftExtraDataCodec.EXTRA_VANITY_LENGTH). +const ExtraVanityLength = 32 + +// SealLength is the encoded length of a single committed seal: R || S || V, +// with V as a 0/1 recovery identifier (Besu: SECPSignature.encodedBytes()). +const SealLength = 65 + +var ( + // ErrShortVanity is returned when the vanity prefix is not ExtraVanityLength bytes. + ErrShortVanity = errors.New("qbft: vanity data must be 32 bytes") + // ErrNoValidators is returned when a header carries an empty validator set. + ErrNoValidators = errors.New("qbft: header carries no validators") + // ErrBadSealLength is returned when a committed seal is not SealLength bytes. + ErrBadSealLength = errors.New("qbft: committed seal must be 65 bytes") +) + +// emptyRLPList is the encoding of an empty RLP list, used for an absent vote and +// for the seal list in the two seal-excluding encodings. +var emptyRLPList = rlp.RawValue{0xc0} + +// ExtraData is the decoded contents of a QBFT header's extraData field. +// +// Besu writes it as RLP([vanity, [validators...], vote, round, [seals...]]) +// where vote is either an empty list or [address, voteByte]. The vote is kept as +// a raw RLP element rather than parsed: re-encoding must reproduce the original +// bytes exactly, and nothing in seal verification needs its contents. +type ExtraData struct { + Vanity []byte + Validators []common.Address + Vote rlp.RawValue + Round uint32 + Seals [][]byte +} + +// DecodeExtraData parses the extraData field of a QBFT block header. +func DecodeExtraData(b []byte) (*ExtraData, error) { + var e ExtraData + if err := rlp.DecodeBytes(b, &e); err != nil { + return nil, fmt.Errorf("qbft: decoding extraData: %w", err) + } + if len(e.Vanity) != ExtraVanityLength { + return nil, ErrShortVanity + } + for _, s := range e.Seals { + if len(s) != SealLength { + return nil, ErrBadSealLength + } + } + return &e, nil +} + +// Encode re-encodes the extraData verbatim. Decoding and re-encoding an +// unmodified header must be a byte-for-byte round trip. +func (e *ExtraData) Encode() ([]byte, error) { + return rlp.EncodeToBytes(e) +} + +// EncodeWithoutCommitSeals encodes the extraData with the round number preserved +// and an empty seal list. This is the form used to build the digest validators +// sign (Besu: BftExtraDataCodec.encodeWithoutCommitSeals). +func (e *ExtraData) EncodeWithoutCommitSeals() ([]byte, error) { + c := *e + c.Seals = nil + return rlp.EncodeToBytes(&c) +} + +// EncodeWithoutCommitSealsAndRound encodes the extraData with the round number +// zeroed and an empty seal list. This is the form used for the canonical on-chain +// block hash, since both fields vary across the candidate blocks circulated at a +// given height (Besu: BftExtraDataCodec.encodeWithoutCommitSealsAndRoundNumber). +func (e *ExtraData) EncodeWithoutCommitSealsAndRound() ([]byte, error) { + c := *e + c.Round = 0 + c.Seals = nil + return rlp.EncodeToBytes(&c) +} + +// EncodeRLP implements rlp.Encoder. An empty vote and an empty seal list must both +// serialise as empty RLP lists rather than as empty byte strings, which is what a +// nil slice would otherwise produce. +func (e *ExtraData) EncodeRLP(w io.Writer) error { + vote := e.Vote + if len(vote) == 0 { + vote = emptyRLPList + } + validators := e.Validators + if validators == nil { + validators = []common.Address{} + } + seals := e.Seals + if seals == nil { + seals = [][]byte{} + } + return rlp.Encode(w, []any{e.Vanity, validators, vote, e.Round, seals}) +} diff --git a/x/qbftclient/types/hashing.go b/x/qbftclient/types/hashing.go new file mode 100644 index 00000000..97fb7257 --- /dev/null +++ b/x/qbftclient/types/hashing.go @@ -0,0 +1,91 @@ +package types + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" +) + +// ErrNoNumber is returned for a header with no block number. +var ErrNoNumber = errors.New("qbft: header has no number") + +// CommitSealHash returns the digest each validator signs to produce a committed +// seal: keccak256 of the RLP-encoded header, with extraData re-encoded so that the +// round number is preserved and the seal list is empty. +// +// Besu: BftBlockHashing.calculateDataHashForCommittedSeal. +func CommitSealHash(h *ethtypes.Header) (common.Hash, error) { + return hashWithReencodedExtra(h, (*ExtraData).EncodeWithoutCommitSeals) +} + +// BlockHash returns the canonical on-chain hash of a QBFT header: keccak256 of the +// RLP-encoded header, with extraData re-encoded so that the round number is zeroed +// and the seal list is empty. Both fields vary across the candidate blocks +// circulated at a given height, so neither can contribute to the block's identity. +// +// Besu: BftBlockHashing.calculateHashOfBftBlockOnchain. +func BlockHash(h *ethtypes.Header) (common.Hash, error) { + return hashWithReencodedExtra(h, (*ExtraData).EncodeWithoutCommitSealsAndRound) +} + +// hashWithReencodedExtra RLP-encodes h with its extraData replaced by enc(extraData) +// and returns the keccak256 of the result. +// +// The genesis header is hashed with its extraData untouched — Besu skips the +// re-encode at height 0 (BftBlockHashing.serializeHeader), because the genesis +// extraData is hand-written and carries neither round nor seals. +func hashWithReencodedExtra(h *ethtypes.Header, enc func(*ExtraData) ([]byte, error)) (common.Hash, error) { + if h.Number == nil { + return common.Hash{}, ErrNoNumber + } + + extra := h.Extra + if h.Number.Sign() != 0 { + e, err := DecodeExtraData(h.Extra) + if err != nil { + return common.Hash{}, err + } + if extra, err = enc(e); err != nil { + return common.Hash{}, fmt.Errorf("qbft: re-encoding extraData: %w", err) + } + } + + // Shallow copy: only Extra is replaced, so sharing the header's pointer fields + // with the caller is safe. + c := *h + c.Extra = extra + + b, err := rlp.EncodeToBytes(&c) + if err != nil { + return common.Hash{}, fmt.Errorf("qbft: encoding header: %w", err) + } + return crypto.Keccak256Hash(b), nil +} + +// RecoverCommitters returns the address behind each committed seal in the header, +// in the order the seals appear. It does not check membership or quorum — see +// VerifyCommitSeals for that. +func RecoverCommitters(h *ethtypes.Header) ([]common.Address, error) { + e, err := DecodeExtraData(h.Extra) + if err != nil { + return nil, err + } + digest, err := CommitSealHash(h) + if err != nil { + return nil, err + } + + out := make([]common.Address, 0, len(e.Seals)) + for i, seal := range e.Seals { + pub, err := crypto.SigToPub(digest.Bytes(), seal) + if err != nil { + return nil, fmt.Errorf("qbft: recovering seal %d: %w", i, err) + } + out = append(out, crypto.PubkeyToAddress(*pub)) + } + return out, nil +} diff --git a/x/qbftclient/types/path.go b/x/qbftclient/types/path.go new file mode 100644 index 00000000..46fe1d5a --- /dev/null +++ b/x/qbftclient/types/path.go @@ -0,0 +1,98 @@ +package types + +import ( + "encoding/binary" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// IBCStoreSlot is the ERC-7201 namespace slot of solidity-ibc-eureka's commitment +// store, and the base slot of the `commitments` mapping — which is the first field +// of the namespaced struct, so it sits at the namespace slot itself. +// +// solidity-ibc-eureka: contracts/utils/IBCStoreUpgradeable.sol +// bytes32 private constant IBCSTORE_STORAGE_SLOT = +// 0x1260944489272988d9df285149b5aa1b0f48f2136d6f416159f840a3e0747600; +// // keccak256(abi.encode(uint256(keccak256("ibc.storage.IBCStore")) - 1)) & ~bytes32(uint256(0xff)) +// +// struct IBCStoreStorage { +// mapping(bytes32 hashedPath => bytes32 commitment) commitments; // field 0 +// mapping(string clientId => uint64 prevSeqSend) prevSequenceSends; +// } +// +// TestIBCStoreSlot recomputes it from the namespace string rather than trusting the +// literal, because a wrong slot does not fail loudly — it proves the wrong storage. +var IBCStoreSlot = common.HexToHash("0x1260944489272988d9df285149b5aa1b0f48f2136d6f416159f840a3e0747600") + +// ICS-24 path discriminators, as solidity-ibc-eureka packs them. +// +// contracts/utils/ICS24Host.sol — abi.encodePacked(clientId, uint8(kind), be64(sequence)) +// +// These are byte-identical to what ibc-go produces on this side: +// +// modules/core/24-host/v2/packet_keys.go +// PacketCommitmentBasePrefix = 1, PacketReceiptBasePrefix = 2, PacketAcknowledgementBasePrefix = 3 +// PacketCommitmentKey(channelID, sequence) = []byte(channelID) || byte(1) || be64(sequence) +// +// The two implementations agree, which is why VerifyMembership hashes the path it +// is handed rather than rebuilding one: there is nothing to diverge. +const ( + pathKindCommitment = 1 + pathKindReceipt = 2 + pathKindAck = 3 +) + +// PacketCommitmentPath returns the ICS-24 path of a packet commitment in the packed +// form the EVM side uses: the client id's raw bytes, a one-byte discriminator, then +// the sequence big-endian. There is no length prefix — abi.encodePacked concatenates. +func PacketCommitmentPath(clientID string, sequence uint64) []byte { + return packedPath(clientID, pathKindCommitment, sequence) +} + +// PacketReceiptPath returns the ICS-24 path of a packet receipt. +func PacketReceiptPath(clientID string, sequence uint64) []byte { + return packedPath(clientID, pathKindReceipt, sequence) +} + +// PacketAckPath returns the ICS-24 path of a packet acknowledgement commitment. +func PacketAckPath(clientID string, sequence uint64) []byte { + return packedPath(clientID, pathKindAck, sequence) +} + +func packedPath(clientID string, kind byte, sequence uint64) []byte { + out := make([]byte, 0, len(clientID)+1+8) + out = append(out, clientID...) + out = append(out, kind) + return binary.BigEndian.AppendUint64(out, sequence) +} + +// CommitmentSlot returns the storage slot holding the commitment for path. +// +// Two steps, both fixed by the contract: the mapping key is keccak256(path) +// (ICS24Host.packetCommitmentKeyCalldata), and Solidity places mapping entries at +// keccak256(key ++ baseSlot) for a value-type key. +func CommitmentSlot(path []byte) common.Hash { + key := crypto.Keccak256(path) + + buf := make([]byte, 0, 64) + buf = append(buf, key...) + buf = append(buf, IBCStoreSlot.Bytes()...) + return crypto.Keccak256Hash(buf) +} + +// PacketCommitmentSlot is the slot a packet commitment for (clientID, sequence) +// occupies in the counterparty's storage trie. +func PacketCommitmentSlot(clientID string, sequence uint64) common.Hash { + return CommitmentSlot(PacketCommitmentPath(clientID, sequence)) +} + +// PacketReceiptSlot is the slot a packet receipt occupies. +func PacketReceiptSlot(clientID string, sequence uint64) common.Hash { + return CommitmentSlot(PacketReceiptPath(clientID, sequence)) +} + +// PacketAckSlot is the slot a packet acknowledgement commitment occupies. +func PacketAckSlot(clientID string, sequence uint64) common.Hash { + return CommitmentSlot(PacketAckPath(clientID, sequence)) +} diff --git a/x/qbftclient/types/path_test.go b/x/qbftclient/types/path_test.go new file mode 100644 index 00000000..81b0bea3 --- /dev/null +++ b/x/qbftclient/types/path_test.go @@ -0,0 +1,95 @@ +package types_test + +import ( + "encoding/binary" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// TestIBCStoreSlot recomputes the ERC-7201 namespace slot from its defining string +// instead of trusting the hex literal copied out of the contract. +// +// A wrong slot is the worst kind of wrong: proofs still verify, against storage that +// belongs to something else entirely. +// +// solidity-ibc-eureka, contracts/utils/IBCStoreUpgradeable.sol: +// keccak256(abi.encode(uint256(keccak256("ibc.storage.IBCStore")) - 1)) & ~bytes32(uint256(0xff)) +func TestIBCStoreSlot(t *testing.T) { + inner := new(big.Int).SetBytes(crypto.Keccak256([]byte("ibc.storage.IBCStore"))) + inner.Sub(inner, big.NewInt(1)) + + // abi.encode of a uint256 is its 32-byte big-endian form. + got := crypto.Keccak256Hash(common.BigToHash(inner).Bytes()) + got[31] &= 0x00 // & ~bytes32(uint256(0xff)) + + if got != types.IBCStoreSlot { + t.Errorf("IBCStoreSlot = %s, recomputed %s", types.IBCStoreSlot, got) + } +} + +// The path is abi.encodePacked(clientId, uint8(kind), be64(sequence)) — no length +// prefix on the string, which is what distinguishes encodePacked from encode. +func TestPacketCommitmentPath(t *testing.T) { + const clientID = "qbft-0" + const sequence = 42 + + got := types.PacketCommitmentPath(clientID, sequence) + + want := append([]byte(clientID), 0x01) + want = binary.BigEndian.AppendUint64(want, sequence) + + if string(got) != string(want) { + t.Errorf("path = %x, want %x", got, want) + } + if len(got) != len(clientID)+9 { + t.Errorf("path length = %d, want %d", len(got), len(clientID)+9) + } +} + +// The three path kinds must not collide, or a receipt could satisfy a proof asked +// for a commitment. +func TestPathKindsDiffer(t *testing.T) { + const clientID = "qbft-0" + const sequence = 7 + + commitment := types.PacketCommitmentSlot(clientID, sequence) + receipt := types.PacketReceiptSlot(clientID, sequence) + ack := types.PacketAckSlot(clientID, sequence) + + if commitment == receipt || commitment == ack || receipt == ack { + t.Errorf("path kinds collide: commitment %s receipt %s ack %s", commitment, receipt, ack) + } +} + +// Solidity places mapping entries at keccak256(key ++ baseSlot) for value-type keys. +func TestCommitmentSlot(t *testing.T) { + path := types.PacketCommitmentPath("qbft-0", 1) + + buf := append(crypto.Keccak256(path), types.IBCStoreSlot.Bytes()...) + want := crypto.Keccak256Hash(buf) + + if got := types.CommitmentSlot(path); got != want { + t.Errorf("slot = %s, want %s", got, want) + } +} + +func TestSequenceChangesSlot(t *testing.T) { + a := types.PacketCommitmentSlot("qbft-0", 1) + b := types.PacketCommitmentSlot("qbft-0", 2) + if a == b { + t.Error("different sequences must map to different slots") + } +} + +func TestClientIDChangesSlot(t *testing.T) { + a := types.PacketCommitmentSlot("qbft-0", 1) + b := types.PacketCommitmentSlot("qbft-1", 1) + if a == b { + t.Error("different clients must map to different slots") + } +} diff --git a/x/qbftclient/types/proof.go b/x/qbftclient/types/proof.go new file mode 100644 index 00000000..3fa8cf44 --- /dev/null +++ b/x/qbftclient/types/proof.go @@ -0,0 +1,164 @@ +package types + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethdb/memorydb" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie" +) + +var ( + // ErrAccountAbsent is returned when the account proof proves the account is not + // in the state trie. It is an error for a membership proof and the expected + // outcome for a non-membership one. + ErrAccountAbsent = errors.New("qbft: account absent from the state trie") + // ErrSlotAbsent is returned when the storage proof proves the slot is unset. + ErrSlotAbsent = errors.New("qbft: storage slot absent") + // ErrSlotPresent is returned when a non-membership check finds a value. + ErrSlotPresent = errors.New("qbft: storage slot is set") + // ErrCommitmentMismatch is returned when a proven slot holds a value other than + // the one expected. + ErrCommitmentMismatch = errors.New("qbft: commitment mismatch") +) + +// VerifyAccount proves the account at addr against a block's state root and returns +// it. The proof is the `accountProof` array of an eth_getProof response, each entry +// an RLP-encoded trie node. +func VerifyAccount(stateRoot common.Hash, addr common.Address, accountProof [][]byte) (*ethtypes.StateAccount, error) { + blob, err := verifyMPT(stateRoot, crypto.Keccak256(addr.Bytes()), accountProof) + if err != nil { + return nil, fmt.Errorf("qbft: account proof: %w", err) + } + if blob == nil { + return nil, fmt.Errorf("%w: %s", ErrAccountAbsent, addr) + } + + var acc ethtypes.StateAccount + if err := rlp.DecodeBytes(blob, &acc); err != nil { + return nil, fmt.Errorf("qbft: decoding account %s: %w", addr, err) + } + return &acc, nil +} + +// VerifyStorage proves slot against an account's storage root and returns the stored +// value. +// +// The value comes back exactly as the chain stored it: leading zeroes trimmed, then +// RLP-encoded into the leaf (geth: core/state/state_object.go:340,385 — +// rlp(common.TrimLeftZeroes(value))). A 32-byte word therefore arrives shorter than +// 32 bytes whenever it has leading zeroes, so a caller comparing against a padded +// commitment must left-pad this result rather than compare it raw. +func VerifyStorage(storageRoot, slot common.Hash, storageProof [][]byte) ([]byte, error) { + blob, err := verifyMPT(storageRoot, crypto.Keccak256(slot.Bytes()), storageProof) + if err != nil { + return nil, fmt.Errorf("qbft: storage proof: %w", err) + } + if blob == nil { + return nil, fmt.Errorf("%w: %s", ErrSlotAbsent, slot) + } + + var value []byte + if err := rlp.DecodeBytes(blob, &value); err != nil { + return nil, fmt.Errorf("qbft: decoding storage value at %s: %w", slot, err) + } + return value, nil +} + +// VerifyStorageValue is the two-step form the light client uses: prove the account +// against the header's state root, then prove the slot against that account's +// storage root. Splitting it any other way would let a caller verify a slot against +// a storage root nobody proved. +func VerifyStorageValue( + stateRoot common.Hash, + addr common.Address, + slot common.Hash, + accountProof, storageProof [][]byte, +) ([]byte, error) { + acc, err := VerifyAccount(stateRoot, addr, accountProof) + if err != nil { + return nil, err + } + return VerifyStorage(acc.Root, slot, storageProof) +} + +// VerifyCommitment proves that the 32-byte word at slot equals want. +// +// This is the form the light client should use, and the reason it exists rather +// than leaving callers to compare VerifyStorageValue's result themselves: the trie +// stores values with leading zeroes trimmed, so a raw comparison against a padded +// commitment mismatches whenever the commitment happens to start with a zero byte — +// roughly one time in 256, and only in production. +func VerifyCommitment( + stateRoot common.Hash, + addr common.Address, + slot, want common.Hash, + accountProof, storageProof [][]byte, +) error { + value, err := VerifyStorageValue(stateRoot, addr, slot, accountProof, storageProof) + if err != nil { + return err + } + + if got := common.BytesToHash(value); got != want { + return fmt.Errorf("%w: slot %s holds %s, want %s", ErrCommitmentMismatch, slot, got, want) + } + return nil +} + +// VerifyStorageAbsent proves that slot holds no value under the account at addr. +// This backs the light client's non-membership check. +func VerifyStorageAbsent( + stateRoot common.Hash, + addr common.Address, + slot common.Hash, + accountProof, storageProof [][]byte, +) error { + acc, err := VerifyAccount(stateRoot, addr, accountProof) + if err != nil { + // An absent account trivially has no storage, so that is a valid proof of + // absence rather than a failure. + if errors.Is(err, ErrAccountAbsent) { + return nil + } + return err + } + + // An account with an empty storage trie holds nothing at any slot, and there + // are no nodes to walk to show it — trie.VerifyProof would fail looking for a + // root that was never written. This is the state a freshly deployed contract is + // in, so it is the first timeout's proof, not an exotic case. + if acc.Root == ethtypes.EmptyRootHash { + return nil + } + + blob, err := verifyMPT(acc.Root, crypto.Keccak256(slot.Bytes()), storageProof) + if err != nil { + return fmt.Errorf("qbft: storage proof: %w", err) + } + if blob != nil { + return fmt.Errorf("%w: %s", ErrSlotPresent, slot) + } + return nil +} + +// verifyMPT walks a Merkle-Patricia proof for key under root. It returns a nil value +// with a nil error when the proof establishes the key's absence — trie.VerifyProof's +// convention, preserved here so membership and non-membership share one path. +// +// Proof nodes are staged in a fresh in-memory store keyed by their keccak256 hash, +// which is how the trie walker looks them up; anything the walk does not reach is +// simply never read. +func verifyMPT(root common.Hash, key []byte, nodes [][]byte) ([]byte, error) { + db := memorydb.New() + for i, n := range nodes { + if err := db.Put(crypto.Keccak256(n), n); err != nil { + return nil, fmt.Errorf("staging proof node %d: %w", i, err) + } + } + return trie.VerifyProof(root, key, db) +} diff --git a/x/qbftclient/types/proof_test.go b/x/qbftclient/types/proof_test.go new file mode 100644 index 00000000..32ac348d --- /dev/null +++ b/x/qbftclient/types/proof_test.go @@ -0,0 +1,181 @@ +package types_test + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// State fixtures come from x/qbftclient/testutil for the same reason the header +// builders do: the storage-value encoding has to match the chain exactly, and one +// copy of it is the only way that stays true. +type fixture struct{ qbfttestutil.State } + +func newFixture(t *testing.T, slots map[common.Hash][]byte) fixture { + t.Helper() + return fixture{qbfttestutil.NewState(t, common.HexToAddress("0x00000000000000000000000000000000cafebabe"), slots)} +} + +func (f fixture) storageProof(t *testing.T, slot common.Hash) [][]byte { + t.Helper() + return f.State.StorageProof(t, slot) +} + +// The packet-commitment path: prove the contract against the header's state root, +// then the commitment slot against that account's storage root. +func TestVerifyStorageValue(t *testing.T) { + slot := common.HexToHash("0x07") + want := []byte{0xde, 0xad, 0xbe, 0xef} + f := newFixture(t, map[common.Hash][]byte{slot: want}) + + got, err := types.VerifyStorageValue(f.Root, f.Contract, slot, f.AccountProof, f.storageProof(t, slot)) + if err != nil { + t.Fatalf("VerifyStorageValue: %v", err) + } + if string(got) != string(want) { + t.Errorf("value = %x, want %x", got, want) + } +} + +// Trie leaves hold RLP, which trims leading zeroes — so a caller comparing a padded +// 32-byte commitment against the raw result would mismatch. Pinned so the adapter +// left-pads deliberately rather than by accident. +func TestVerifyStorageValue_TrimsLeadingZeroes(t *testing.T) { + slot := common.HexToHash("0x01") + stored := common.HexToHash("0x000000000000000000000000000000000000000000000000000000000000002a") + f := newFixture(t, map[common.Hash][]byte{slot: stored.Bytes()}) + + got, err := types.VerifyStorageValue(f.Root, f.Contract, slot, f.AccountProof, f.storageProof(t, slot)) + if err != nil { + t.Fatalf("VerifyStorageValue: %v", err) + } + if len(got) != 1 || got[0] != 0x2a { + t.Errorf("value = %x, want 2a — RLP should have trimmed the padding", got) + } +} + +// The case a raw comparison gets wrong: a commitment whose first byte is zero comes +// back 31 bytes, so comparing it to the padded 32-byte value fails. Roughly one +// commitment in 256 — i.e. only ever in production. +func TestVerifyCommitment_LeadingZeroCommitment(t *testing.T) { + slot := common.HexToHash("0x07") + want := common.HexToHash("0x00ff112233445566778899aabbccddeeff00112233445566778899aabbccddee") + f := newFixture(t, map[common.Hash][]byte{slot: want.Bytes()}) + + if err := types.VerifyCommitment(f.Root, f.Contract, slot, want, f.AccountProof, f.storageProof(t, slot)); err != nil { + t.Errorf("commitment with a leading zero byte should verify, got %v", err) + } +} + +func TestVerifyCommitment_Mismatch(t *testing.T) { + slot := common.HexToHash("0x07") + stored := common.HexToHash("0x11") + f := newFixture(t, map[common.Hash][]byte{slot: stored.Bytes()}) + + err := types.VerifyCommitment(f.Root, f.Contract, slot, common.HexToHash("0x22"), f.AccountProof, f.storageProof(t, slot)) + if !errors.Is(err, types.ErrCommitmentMismatch) { + t.Errorf("want ErrCommitmentMismatch, got %v", err) + } +} + +// End to end against the real slot derivation: a packet commitment written at the +// slot solidity-ibc-eureka would use, proved back out of the state trie. +func TestVerifyCommitment_AtDerivedPacketSlot(t *testing.T) { + const clientID = "qbft-0" + const sequence = 3 + + slot := types.PacketCommitmentSlot(clientID, sequence) + want := common.HexToHash("0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789") + f := newFixture(t, map[common.Hash][]byte{slot: want.Bytes()}) + + if err := types.VerifyCommitment(f.Root, f.Contract, slot, want, f.AccountProof, f.storageProof(t, slot)); err != nil { + t.Errorf("packet commitment at its derived slot should verify, got %v", err) + } +} + +func TestVerifyStorageValue_WrongStateRoot(t *testing.T) { + slot := common.HexToHash("0x07") + f := newFixture(t, map[common.Hash][]byte{slot: {0x01}}) + + _, err := types.VerifyStorageValue(common.HexToHash("0xbad"), f.Contract, slot, f.AccountProof, f.storageProof(t, slot)) + if err == nil { + t.Fatal("a proof against the wrong state root must not verify") + } +} + +func TestVerifyStorageValue_TamperedProofNode(t *testing.T) { + slot := common.HexToHash("0x07") + f := newFixture(t, map[common.Hash][]byte{slot: {0x01}}) + + tampered := f.storageProof(t, slot) + tampered[len(tampered)-1] = append(common.CopyBytes(tampered[len(tampered)-1]), 0x00) + + if _, err := types.VerifyStorageValue(f.Root, f.Contract, slot, f.AccountProof, tampered); err == nil { + t.Fatal("a tampered proof node must not verify") + } +} + +func TestVerifyStorageValue_AbsentSlot(t *testing.T) { + present := common.HexToHash("0x07") + absent := common.HexToHash("0x08") + f := newFixture(t, map[common.Hash][]byte{present: {0x01}}) + + _, err := types.VerifyStorageValue(f.Root, f.Contract, absent, f.AccountProof, f.storageProof(t, absent)) + if !errors.Is(err, types.ErrSlotAbsent) { + t.Errorf("absent slot should report ErrSlotAbsent, got %v", err) + } +} + +// Non-membership backs the light client's VerifyNonMembership. +func TestVerifyStorageAbsent(t *testing.T) { + present := common.HexToHash("0x07") + absent := common.HexToHash("0x08") + f := newFixture(t, map[common.Hash][]byte{present: {0x01}}) + + if err := types.VerifyStorageAbsent(f.Root, f.Contract, absent, f.AccountProof, f.storageProof(t, absent)); err != nil { + t.Errorf("absence proof should verify, got %v", err) + } + + err := types.VerifyStorageAbsent(f.Root, f.Contract, present, f.AccountProof, f.storageProof(t, present)) + if !errors.Is(err, types.ErrSlotPresent) { + t.Errorf("a set slot must not pass an absence check, got %v", err) + } +} + +// A contract with no storage yet — the state a freshly deployed ICS20Transfer is +// in — holds nothing at any slot, and there are no trie nodes to demonstrate it. +// The corridor's first timeout depends on this proving absent rather than erroring. +func TestVerifyStorageAbsent_EmptyStorageTrie(t *testing.T) { + f := newFixture(t, map[common.Hash][]byte{}) + slot := common.HexToHash("0x07") + + if err := types.VerifyStorageAbsent(f.Root, f.Contract, slot, f.AccountProof, f.storageProof(t, slot)); err != nil { + t.Errorf("a slot in an empty storage trie must prove absent, got %v", err) + } +} + +// The mirror: nothing can be proved *present* under an empty storage trie. +func TestVerifyStorageValue_EmptyStorageTrie(t *testing.T) { + f := newFixture(t, map[common.Hash][]byte{}) + slot := common.HexToHash("0x07") + + if _, err := types.VerifyStorageValue(f.Root, f.Contract, slot, f.AccountProof, f.storageProof(t, slot)); err == nil { + t.Error("nothing can be present under an empty storage trie") + } +} + +func TestVerifyAccount_Absent(t *testing.T) { + f := newFixture(t, map[common.Hash][]byte{common.HexToHash("0x07"): {0x01}}) + other := common.HexToAddress("0x000000000000000000000000000000000000dead") + + // The proof covers f.Contract, so it cannot establish anything about another + // account — not even its absence. + _, err := types.VerifyAccount(f.Root, other, f.AccountProof) + if err == nil { + t.Fatal("proving an account the proof does not cover must fail") + } +} diff --git a/x/qbftclient/types/qbftclient.pb.go b/x/qbftclient/types/qbftclient.pb.go new file mode 100644 index 00000000..f7aeee05 --- /dev/null +++ b/x/qbftclient/types/qbftclient.pb.go @@ -0,0 +1,1455 @@ +// Code generated by protoc-gen-gogo. DO NOT EDIT. +// source: qbftclient/qbftclient.proto + +package types + +import ( + fmt "fmt" + proto "github.com/cosmos/gogoproto/proto" + io "io" + math "math" + math_bits "math/bits" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package + +// 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. +type ClientState struct { + // 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. + ChainId uint64 `protobuf:"varint,1,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + // 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. + TrustingPeriod uint64 `protobuf:"varint,2,opt,name=trusting_period,json=trustingPeriod,proto3" json:"trusting_period,omitempty"` + // 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. + MaxClockDrift uint64 `protobuf:"varint,3,opt,name=max_clock_drift,json=maxClockDrift,proto3" json:"max_clock_drift,omitempty"` + // latest_height is the highest block number this client has verified. + LatestHeight uint64 `protobuf:"varint,4,opt,name=latest_height,json=latestHeight,proto3" json:"latest_height,omitempty"` + // 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. + FrozenHeight uint64 `protobuf:"varint,5,opt,name=frozen_height,json=frozenHeight,proto3" json:"frozen_height,omitempty"` + // 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. + IbcContractAddress []byte `protobuf:"bytes,6,opt,name=ibc_contract_address,json=ibcContractAddress,proto3" json:"ibc_contract_address,omitempty"` +} + +func (m *ClientState) Reset() { *m = ClientState{} } +func (m *ClientState) String() string { return proto.CompactTextString(m) } +func (*ClientState) ProtoMessage() {} +func (*ClientState) Descriptor() ([]byte, []int) { + return fileDescriptor_ff4b20c878598c3a, []int{0} +} +func (m *ClientState) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *ClientState) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_ClientState.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *ClientState) XXX_Merge(src proto.Message) { + xxx_messageInfo_ClientState.Merge(m, src) +} +func (m *ClientState) XXX_Size() int { + return m.Size() +} +func (m *ClientState) XXX_DiscardUnknown() { + xxx_messageInfo_ClientState.DiscardUnknown(m) +} + +var xxx_messageInfo_ClientState proto.InternalMessageInfo + +func (m *ClientState) GetChainId() uint64 { + if m != nil { + return m.ChainId + } + return 0 +} + +func (m *ClientState) GetTrustingPeriod() uint64 { + if m != nil { + return m.TrustingPeriod + } + return 0 +} + +func (m *ClientState) GetMaxClockDrift() uint64 { + if m != nil { + return m.MaxClockDrift + } + return 0 +} + +func (m *ClientState) GetLatestHeight() uint64 { + if m != nil { + return m.LatestHeight + } + return 0 +} + +func (m *ClientState) GetFrozenHeight() uint64 { + if m != nil { + return m.FrozenHeight + } + return 0 +} + +func (m *ClientState) GetIbcContractAddress() []byte { + if m != nil { + return m.IbcContractAddress + } + return nil +} + +// ConsensusState is what the client remembers about one verified block. +type ConsensusState struct { + // 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. + Timestamp uint64 `protobuf:"varint,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + // state_root is the block's 32-byte state root, the anchor every + // Merkle-Patricia membership proof is verified against. + StateRoot []byte `protobuf:"bytes,2,opt,name=state_root,json=stateRoot,proto3" json:"state_root,omitempty"` + // 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. + Validators [][]byte `protobuf:"bytes,3,rep,name=validators,proto3" json:"validators,omitempty"` +} + +func (m *ConsensusState) Reset() { *m = ConsensusState{} } +func (m *ConsensusState) String() string { return proto.CompactTextString(m) } +func (*ConsensusState) ProtoMessage() {} +func (*ConsensusState) Descriptor() ([]byte, []int) { + return fileDescriptor_ff4b20c878598c3a, []int{1} +} +func (m *ConsensusState) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *ConsensusState) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_ConsensusState.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *ConsensusState) XXX_Merge(src proto.Message) { + xxx_messageInfo_ConsensusState.Merge(m, src) +} +func (m *ConsensusState) XXX_Size() int { + return m.Size() +} +func (m *ConsensusState) XXX_DiscardUnknown() { + xxx_messageInfo_ConsensusState.DiscardUnknown(m) +} + +var xxx_messageInfo_ConsensusState proto.InternalMessageInfo + +func (m *ConsensusState) GetTimestamp() uint64 { + if m != nil { + return m.Timestamp + } + return 0 +} + +func (m *ConsensusState) GetStateRoot() []byte { + if m != nil { + return m.StateRoot + } + return nil +} + +func (m *ConsensusState) GetValidators() [][]byte { + if m != nil { + return m.Validators + } + return nil +} + +// Header is a ClientMessage carrying one Besu block header to verify. +type Header struct { + // 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. + RlpHeader []byte `protobuf:"bytes,1,opt,name=rlp_header,json=rlpHeader,proto3" json:"rlp_header,omitempty"` +} + +func (m *Header) Reset() { *m = Header{} } +func (m *Header) String() string { return proto.CompactTextString(m) } +func (*Header) ProtoMessage() {} +func (*Header) Descriptor() ([]byte, []int) { + return fileDescriptor_ff4b20c878598c3a, []int{2} +} +func (m *Header) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Header) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Header.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Header) XXX_Merge(src proto.Message) { + xxx_messageInfo_Header.Merge(m, src) +} +func (m *Header) XXX_Size() int { + return m.Size() +} +func (m *Header) XXX_DiscardUnknown() { + xxx_messageInfo_Header.DiscardUnknown(m) +} + +var xxx_messageInfo_Header proto.InternalMessageInfo + +func (m *Header) GetRlpHeader() []byte { + if m != nil { + return m.RlpHeader + } + return nil +} + +// 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. +type StorageProof struct { + // account_proof is the list of RLP-encoded trie nodes from the state root down + // to the contract's account. + AccountProof [][]byte `protobuf:"bytes,1,rep,name=account_proof,json=accountProof,proto3" json:"account_proof,omitempty"` + // storage_proof is the list of RLP-encoded trie nodes from the contract's + // storage root down to the slot. + StorageProof [][]byte `protobuf:"bytes,2,rep,name=storage_proof,json=storageProof,proto3" json:"storage_proof,omitempty"` +} + +func (m *StorageProof) Reset() { *m = StorageProof{} } +func (m *StorageProof) String() string { return proto.CompactTextString(m) } +func (*StorageProof) ProtoMessage() {} +func (*StorageProof) Descriptor() ([]byte, []int) { + return fileDescriptor_ff4b20c878598c3a, []int{3} +} +func (m *StorageProof) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *StorageProof) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_StorageProof.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *StorageProof) XXX_Merge(src proto.Message) { + xxx_messageInfo_StorageProof.Merge(m, src) +} +func (m *StorageProof) XXX_Size() int { + return m.Size() +} +func (m *StorageProof) XXX_DiscardUnknown() { + xxx_messageInfo_StorageProof.DiscardUnknown(m) +} + +var xxx_messageInfo_StorageProof proto.InternalMessageInfo + +func (m *StorageProof) GetAccountProof() [][]byte { + if m != nil { + return m.AccountProof + } + return nil +} + +func (m *StorageProof) GetStorageProof() [][]byte { + if m != nil { + return m.StorageProof + } + return nil +} + +// 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. +type Misbehaviour struct { + // header_1 is the RLP encoding of the first conflicting header. + Header_1 []byte `protobuf:"bytes,1,opt,name=header_1,json=header1,proto3" json:"header_1,omitempty"` + // header_2 is the RLP encoding of the second conflicting header. + Header_2 []byte `protobuf:"bytes,2,opt,name=header_2,json=header2,proto3" json:"header_2,omitempty"` +} + +func (m *Misbehaviour) Reset() { *m = Misbehaviour{} } +func (m *Misbehaviour) String() string { return proto.CompactTextString(m) } +func (*Misbehaviour) ProtoMessage() {} +func (*Misbehaviour) Descriptor() ([]byte, []int) { + return fileDescriptor_ff4b20c878598c3a, []int{4} +} +func (m *Misbehaviour) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Misbehaviour) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Misbehaviour.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Misbehaviour) XXX_Merge(src proto.Message) { + xxx_messageInfo_Misbehaviour.Merge(m, src) +} +func (m *Misbehaviour) XXX_Size() int { + return m.Size() +} +func (m *Misbehaviour) XXX_DiscardUnknown() { + xxx_messageInfo_Misbehaviour.DiscardUnknown(m) +} + +var xxx_messageInfo_Misbehaviour proto.InternalMessageInfo + +func (m *Misbehaviour) GetHeader_1() []byte { + if m != nil { + return m.Header_1 + } + return nil +} + +func (m *Misbehaviour) GetHeader_2() []byte { + if m != nil { + return m.Header_2 + } + return nil +} + +func init() { + proto.RegisterType((*ClientState)(nil), "qbftclient.ClientState") + proto.RegisterType((*ConsensusState)(nil), "qbftclient.ConsensusState") + proto.RegisterType((*Header)(nil), "qbftclient.Header") + proto.RegisterType((*StorageProof)(nil), "qbftclient.StorageProof") + proto.RegisterType((*Misbehaviour)(nil), "qbftclient.Misbehaviour") +} + +func init() { proto.RegisterFile("qbftclient/qbftclient.proto", fileDescriptor_ff4b20c878598c3a) } + +var fileDescriptor_ff4b20c878598c3a = []byte{ + // 449 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x4c, 0x92, 0xc1, 0x6e, 0xd3, 0x30, + 0x18, 0xc7, 0x9b, 0x75, 0x74, 0xd4, 0x64, 0x9b, 0x14, 0x71, 0x08, 0x02, 0xa2, 0x2a, 0x48, 0xac, + 0x17, 0x96, 0x6d, 0x3c, 0x01, 0x74, 0x87, 0x81, 0x84, 0x34, 0x65, 0x17, 0xc4, 0xc5, 0x72, 0x6c, + 0xb7, 0xb1, 0x48, 0xfc, 0x05, 0xfb, 0xcb, 0xd4, 0xf1, 0x14, 0x3c, 0x16, 0xc7, 0x1d, 0x39, 0xa2, + 0xf6, 0x19, 0xb8, 0xa3, 0xd8, 0xd9, 0x9a, 0x9b, 0xbf, 0xdf, 0xff, 0xd7, 0xaf, 0xfe, 0x2b, 0x26, + 0x2f, 0x7f, 0x14, 0x4b, 0xe4, 0x95, 0x92, 0x1a, 0xb3, 0xdd, 0xf1, 0xb4, 0x31, 0x80, 0x10, 0x91, + 0x1d, 0x49, 0xff, 0x05, 0xe4, 0xd9, 0xc2, 0x1d, 0x6f, 0x90, 0xa1, 0x8c, 0x5e, 0x90, 0xa7, 0xbc, + 0x64, 0x4a, 0x53, 0x25, 0xe2, 0x60, 0x16, 0xcc, 0xf7, 0xf3, 0x03, 0x37, 0x7f, 0x12, 0xd1, 0x09, + 0x39, 0x46, 0xd3, 0x5a, 0x54, 0x7a, 0x45, 0x1b, 0x69, 0x14, 0x88, 0x78, 0xcf, 0x19, 0x47, 0x0f, + 0xf8, 0xda, 0xd1, 0xe8, 0x2d, 0x39, 0xae, 0xd9, 0x9a, 0xf2, 0x0a, 0xf8, 0x77, 0x2a, 0x8c, 0x5a, + 0x62, 0x3c, 0x76, 0xe2, 0x61, 0xcd, 0xd6, 0x8b, 0x8e, 0x5e, 0x76, 0x30, 0x7a, 0x43, 0x0e, 0x2b, + 0x86, 0xd2, 0x22, 0x2d, 0xa5, 0x5a, 0x95, 0x18, 0xef, 0x3b, 0x2b, 0xf4, 0xf0, 0xca, 0xb1, 0x4e, + 0x5a, 0x1a, 0xf8, 0x29, 0xf5, 0x83, 0xf4, 0xc4, 0x4b, 0x1e, 0xf6, 0xd2, 0x19, 0x79, 0xae, 0x0a, + 0x4e, 0x39, 0x68, 0x34, 0x8c, 0x23, 0x65, 0x42, 0x18, 0x69, 0x6d, 0x3c, 0x99, 0x05, 0xf3, 0x30, + 0x8f, 0x54, 0xc1, 0x17, 0x7d, 0xf4, 0xc1, 0x27, 0x69, 0x4d, 0x8e, 0x16, 0xa0, 0xad, 0xd4, 0xb6, + 0xb5, 0xbe, 0xf9, 0x2b, 0x32, 0x45, 0x55, 0x4b, 0x8b, 0xac, 0x6e, 0xfa, 0xea, 0x3b, 0x10, 0xbd, + 0x26, 0xc4, 0x76, 0x1a, 0x35, 0x00, 0xe8, 0x7a, 0x87, 0xf9, 0xd4, 0x91, 0x1c, 0x00, 0xa3, 0x84, + 0x90, 0x5b, 0x56, 0x29, 0xc1, 0x10, 0x8c, 0x8d, 0xc7, 0xb3, 0xf1, 0x3c, 0xcc, 0x07, 0x24, 0x3d, + 0x21, 0x93, 0x2b, 0xc9, 0x84, 0x34, 0xdd, 0x22, 0x53, 0x35, 0xb4, 0x74, 0x93, 0xfb, 0x9f, 0x30, + 0x9f, 0x9a, 0xaa, 0xf1, 0x71, 0xfa, 0x95, 0x84, 0x37, 0x08, 0x86, 0xad, 0xe4, 0xb5, 0x01, 0x58, + 0x76, 0xf5, 0x19, 0xe7, 0xd0, 0x6a, 0xa4, 0x4d, 0x07, 0xe2, 0xc0, 0xed, 0x0e, 0x7b, 0xf8, 0x28, + 0x59, 0xff, 0xa3, 0x5e, 0xda, 0xf3, 0x92, 0x1d, 0x6c, 0x4a, 0x2f, 0x49, 0xf8, 0x45, 0xd9, 0x42, + 0x96, 0xec, 0x56, 0x41, 0x6b, 0xba, 0x2f, 0xed, 0x2f, 0x41, 0xcf, 0xfb, 0x6b, 0x1c, 0xf8, 0xf9, + 0x7c, 0x10, 0x5d, 0xf4, 0x55, 0xfb, 0xe8, 0xe2, 0xe3, 0xe7, 0xdf, 0x9b, 0x24, 0xb8, 0xdf, 0x24, + 0xc1, 0xdf, 0x4d, 0x12, 0xfc, 0xda, 0x26, 0xa3, 0xfb, 0x6d, 0x32, 0xfa, 0xb3, 0x4d, 0x46, 0xdf, + 0xce, 0x56, 0x0a, 0xcb, 0xb6, 0x38, 0xe5, 0x50, 0x67, 0x8d, 0x94, 0xc6, 0xde, 0x59, 0xcc, 0x78, + 0x21, 0xf8, 0x3b, 0x0d, 0x42, 0x66, 0xeb, 0xc1, 0x3b, 0xcc, 0xf0, 0xae, 0x91, 0xb6, 0x98, 0xb8, + 0xe7, 0xf8, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0xff, 0xff, 0xe0, 0x9b, 0x9d, 0x5f, 0xad, 0x02, 0x00, + 0x00, +} + +func (m *ClientState) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ClientState) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *ClientState) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.IbcContractAddress) > 0 { + i -= len(m.IbcContractAddress) + copy(dAtA[i:], m.IbcContractAddress) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.IbcContractAddress))) + i-- + dAtA[i] = 0x32 + } + if m.FrozenHeight != 0 { + i = encodeVarintQbftclient(dAtA, i, uint64(m.FrozenHeight)) + i-- + dAtA[i] = 0x28 + } + if m.LatestHeight != 0 { + i = encodeVarintQbftclient(dAtA, i, uint64(m.LatestHeight)) + i-- + dAtA[i] = 0x20 + } + if m.MaxClockDrift != 0 { + i = encodeVarintQbftclient(dAtA, i, uint64(m.MaxClockDrift)) + i-- + dAtA[i] = 0x18 + } + if m.TrustingPeriod != 0 { + i = encodeVarintQbftclient(dAtA, i, uint64(m.TrustingPeriod)) + i-- + dAtA[i] = 0x10 + } + if m.ChainId != 0 { + i = encodeVarintQbftclient(dAtA, i, uint64(m.ChainId)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *ConsensusState) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ConsensusState) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *ConsensusState) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Validators) > 0 { + for iNdEx := len(m.Validators) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.Validators[iNdEx]) + copy(dAtA[i:], m.Validators[iNdEx]) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.Validators[iNdEx]))) + i-- + dAtA[i] = 0x1a + } + } + if len(m.StateRoot) > 0 { + i -= len(m.StateRoot) + copy(dAtA[i:], m.StateRoot) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.StateRoot))) + i-- + dAtA[i] = 0x12 + } + if m.Timestamp != 0 { + i = encodeVarintQbftclient(dAtA, i, uint64(m.Timestamp)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *Header) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Header) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Header) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.RlpHeader) > 0 { + i -= len(m.RlpHeader) + copy(dAtA[i:], m.RlpHeader) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.RlpHeader))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *StorageProof) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *StorageProof) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *StorageProof) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.StorageProof) > 0 { + for iNdEx := len(m.StorageProof) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.StorageProof[iNdEx]) + copy(dAtA[i:], m.StorageProof[iNdEx]) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.StorageProof[iNdEx]))) + i-- + dAtA[i] = 0x12 + } + } + if len(m.AccountProof) > 0 { + for iNdEx := len(m.AccountProof) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.AccountProof[iNdEx]) + copy(dAtA[i:], m.AccountProof[iNdEx]) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.AccountProof[iNdEx]))) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *Misbehaviour) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Misbehaviour) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Misbehaviour) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Header_2) > 0 { + i -= len(m.Header_2) + copy(dAtA[i:], m.Header_2) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.Header_2))) + i-- + dAtA[i] = 0x12 + } + if len(m.Header_1) > 0 { + i -= len(m.Header_1) + copy(dAtA[i:], m.Header_1) + i = encodeVarintQbftclient(dAtA, i, uint64(len(m.Header_1))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarintQbftclient(dAtA []byte, offset int, v uint64) int { + offset -= sovQbftclient(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ClientState) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.ChainId != 0 { + n += 1 + sovQbftclient(uint64(m.ChainId)) + } + if m.TrustingPeriod != 0 { + n += 1 + sovQbftclient(uint64(m.TrustingPeriod)) + } + if m.MaxClockDrift != 0 { + n += 1 + sovQbftclient(uint64(m.MaxClockDrift)) + } + if m.LatestHeight != 0 { + n += 1 + sovQbftclient(uint64(m.LatestHeight)) + } + if m.FrozenHeight != 0 { + n += 1 + sovQbftclient(uint64(m.FrozenHeight)) + } + l = len(m.IbcContractAddress) + if l > 0 { + n += 1 + l + sovQbftclient(uint64(l)) + } + return n +} + +func (m *ConsensusState) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Timestamp != 0 { + n += 1 + sovQbftclient(uint64(m.Timestamp)) + } + l = len(m.StateRoot) + if l > 0 { + n += 1 + l + sovQbftclient(uint64(l)) + } + if len(m.Validators) > 0 { + for _, b := range m.Validators { + l = len(b) + n += 1 + l + sovQbftclient(uint64(l)) + } + } + return n +} + +func (m *Header) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.RlpHeader) + if l > 0 { + n += 1 + l + sovQbftclient(uint64(l)) + } + return n +} + +func (m *StorageProof) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.AccountProof) > 0 { + for _, b := range m.AccountProof { + l = len(b) + n += 1 + l + sovQbftclient(uint64(l)) + } + } + if len(m.StorageProof) > 0 { + for _, b := range m.StorageProof { + l = len(b) + n += 1 + l + sovQbftclient(uint64(l)) + } + } + return n +} + +func (m *Misbehaviour) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Header_1) + if l > 0 { + n += 1 + l + sovQbftclient(uint64(l)) + } + l = len(m.Header_2) + if l > 0 { + n += 1 + l + sovQbftclient(uint64(l)) + } + return n +} + +func sovQbftclient(x uint64) (n int) { + return (math_bits.Len64(x|1) + 6) / 7 +} +func sozQbftclient(x uint64) (n int) { + return sovQbftclient(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ClientState) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ClientState: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ClientState: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field ChainId", wireType) + } + m.ChainId = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.ChainId |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TrustingPeriod", wireType) + } + m.TrustingPeriod = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TrustingPeriod |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field MaxClockDrift", wireType) + } + m.MaxClockDrift = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.MaxClockDrift |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field LatestHeight", wireType) + } + m.LatestHeight = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.LatestHeight |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 5: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field FrozenHeight", wireType) + } + m.FrozenHeight = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.FrozenHeight |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field IbcContractAddress", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.IbcContractAddress = append(m.IbcContractAddress[:0], dAtA[iNdEx:postIndex]...) + if m.IbcContractAddress == nil { + m.IbcContractAddress = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQbftclient(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQbftclient + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ConsensusState) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ConsensusState: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ConsensusState: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) + } + m.Timestamp = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Timestamp |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field StateRoot", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.StateRoot = append(m.StateRoot[:0], dAtA[iNdEx:postIndex]...) + if m.StateRoot == nil { + m.StateRoot = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Validators", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Validators = append(m.Validators, make([]byte, postIndex-iNdEx)) + copy(m.Validators[len(m.Validators)-1], dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQbftclient(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQbftclient + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Header) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Header: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Header: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field RlpHeader", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.RlpHeader = append(m.RlpHeader[:0], dAtA[iNdEx:postIndex]...) + if m.RlpHeader == nil { + m.RlpHeader = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQbftclient(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQbftclient + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *StorageProof) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: StorageProof: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: StorageProof: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AccountProof", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AccountProof = append(m.AccountProof, make([]byte, postIndex-iNdEx)) + copy(m.AccountProof[len(m.AccountProof)-1], dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field StorageProof", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.StorageProof = append(m.StorageProof, make([]byte, postIndex-iNdEx)) + copy(m.StorageProof[len(m.StorageProof)-1], dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQbftclient(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQbftclient + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Misbehaviour) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Misbehaviour: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Misbehaviour: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Header_1", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Header_1 = append(m.Header_1[:0], dAtA[iNdEx:postIndex]...) + if m.Header_1 == nil { + m.Header_1 = []byte{} + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Header_2", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQbftclient + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthQbftclient + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthQbftclient + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Header_2 = append(m.Header_2[:0], dAtA[iNdEx:postIndex]...) + if m.Header_2 == nil { + m.Header_2 = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQbftclient(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQbftclient + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func skipQbftclient(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowQbftclient + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowQbftclient + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowQbftclient + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLengthQbftclient + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroupQbftclient + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLengthQbftclient + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLengthQbftclient = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflowQbftclient = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroupQbftclient = fmt.Errorf("proto: unexpected end of group") +) diff --git a/x/qbftclient/types/state.go b/x/qbftclient/types/state.go new file mode 100644 index 00000000..a8d0a1fd --- /dev/null +++ b/x/qbftclient/types/state.go @@ -0,0 +1,133 @@ +package types + +import ( + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +var ( + // ErrInvalidClientState is returned when a client state is unusable as configured. + ErrInvalidClientState = errors.New("qbft: invalid client state") + // ErrInvalidConsensusState is returned when a consensus state is malformed. + ErrInvalidConsensusState = errors.New("qbft: invalid consensus state") +) + +// AddressLength is the byte length of an EVM address, restated here so the +// consensus-state checks do not depend on go-ethereum's constant naming. +const AddressLength = common.AddressLength + +// Validate reports whether the client state is usable. It is deliberately strict +// about the trusting period: a client created with a period longer than the +// counterparty's own guarantees will happily verify headers nobody stands behind. +func (cs *ClientState) Validate() error { + if cs.ChainId == 0 { + return fmt.Errorf("%w: chain id must be set", ErrInvalidClientState) + } + if cs.TrustingPeriod == 0 { + return fmt.Errorf("%w: trusting period must be set", ErrInvalidClientState) + } + if len(cs.IbcContractAddress) != AddressLength { + return fmt.Errorf("%w: ibc contract address must be %d bytes, got %d", + ErrInvalidClientState, AddressLength, len(cs.IbcContractAddress)) + } + if cs.FrozenHeight != 0 && cs.FrozenHeight > cs.LatestHeight { + return fmt.Errorf("%w: frozen height %d is above latest height %d", + ErrInvalidClientState, cs.FrozenHeight, cs.LatestHeight) + } + return nil +} + +// IsFrozen reports whether misbehaviour has frozen this client. +func (cs *ClientState) IsFrozen() bool { return cs.FrozenHeight != 0 } + +// ContractAddress returns the counterparty IBC contract whose storage holds packet +// commitments. +func (cs *ClientState) ContractAddress() common.Address { + return common.BytesToAddress(cs.IbcContractAddress) +} + +// TrustingPeriodDuration returns the trusting period as a duration. The proto keeps +// it in seconds because a duration message would pull in a well-known-type import +// for a value that is never sub-second. +func (cs *ClientState) TrustingPeriodDuration() time.Duration { + return time.Duration(cs.TrustingPeriod) * time.Second +} + +// MaxClockDriftDuration returns the permitted clock drift as a duration. +func (cs *ClientState) MaxClockDriftDuration() time.Duration { + return time.Duration(cs.MaxClockDrift) * time.Second +} + +// ValidateBasic reports whether the consensus state is well formed. The name comes +// from ibc-go's exported.ConsensusState interface, which this satisfies. +func (cs *ConsensusState) ValidateBasic() error { + if cs.Timestamp == 0 { + return fmt.Errorf("%w: timestamp must be set", ErrInvalidConsensusState) + } + if len(cs.StateRoot) != common.HashLength { + return fmt.Errorf("%w: state root must be %d bytes, got %d", + ErrInvalidConsensusState, common.HashLength, len(cs.StateRoot)) + } + if len(cs.Validators) == 0 { + return fmt.Errorf("%w: %s", ErrInvalidConsensusState, ErrNoValidators) + } + for i, v := range cs.Validators { + if len(v) != AddressLength { + return fmt.Errorf("%w: validator %d must be %d bytes, got %d", + ErrInvalidConsensusState, i, AddressLength, len(v)) + } + } + return nil +} + +// Root returns the state root every membership proof at this height is verified +// against. +func (cs *ConsensusState) Root() common.Hash { return common.BytesToHash(cs.StateRoot) } + +// ValidatorAddresses returns the stored set in the form the verification core uses. +func (cs *ConsensusState) ValidatorAddresses() []common.Address { + out := make([]common.Address, len(cs.Validators)) + for i, v := range cs.Validators { + out[i] = common.BytesToAddress(v) + } + return out +} + +// Trusted converts a stored consensus state into the plain-Go shape the +// verification core takes. height is supplied by the caller because the consensus +// state is stored keyed by height rather than carrying it. +func (cs *ConsensusState) Trusted(height uint64) TrustedState { + return TrustedState{ + Height: height, + Timestamp: time.Unix(0, int64(cs.Timestamp)).UTC(), + Validators: cs.ValidatorAddresses(), + } +} + +// NewConsensusState builds the consensus state to store for a header that has +// already been verified. +// +// The timestamp is scaled from Besu's seconds to the nanoseconds the ibc-go +// consensus-state interface is defined in; the validator set is the one the header +// carries, which is what makes the set follow the chain. +func NewConsensusState(h *ethtypes.Header) (*ConsensusState, error) { + validators, err := HeaderValidators(h) + if err != nil { + return nil, err + } + + encoded := make([][]byte, len(validators)) + for i, v := range validators { + encoded[i] = v.Bytes() + } + + return &ConsensusState{ + Timestamp: uint64(time.Unix(int64(h.Time), 0).UnixNano()), + StateRoot: h.Root.Bytes(), + Validators: encoded, + }, nil +} diff --git a/x/qbftclient/types/state_test.go b/x/qbftclient/types/state_test.go new file mode 100644 index 00000000..63ade464 --- /dev/null +++ b/x/qbftclient/types/state_test.go @@ -0,0 +1,159 @@ +package types_test + +import ( + "errors" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +func validClientState() *types.ClientState { + return &types.ClientState{ + ChainId: 1338, + TrustingPeriod: uint64((14 * 24 * time.Hour) / time.Second), + MaxClockDrift: 10, + LatestHeight: 100, + IbcContractAddress: common.HexToAddress("0x00000000000000000000000000000000cafebabe").Bytes(), + } +} + +func TestClientStateValidate(t *testing.T) { + if err := validClientState().Validate(); err != nil { + t.Fatalf("valid client state rejected: %v", err) + } + + for name, mutate := range map[string]func(*types.ClientState){ + "zero chain id": func(cs *types.ClientState) { cs.ChainId = 0 }, + "zero trusting period": func(cs *types.ClientState) { cs.TrustingPeriod = 0 }, + "short address": func(cs *types.ClientState) { cs.IbcContractAddress = []byte{0x01} }, + "frozen above latest": func(cs *types.ClientState) { cs.FrozenHeight = cs.LatestHeight + 1 }, + } { + t.Run(name, func(t *testing.T) { + cs := validClientState() + mutate(cs) + if err := cs.Validate(); !errors.Is(err, types.ErrInvalidClientState) { + t.Errorf("want ErrInvalidClientState, got %v", err) + } + }) + } +} + +func TestClientStateFrozen(t *testing.T) { + cs := validClientState() + if cs.IsFrozen() { + t.Error("a fresh client must not report frozen") + } + cs.FrozenHeight = 50 + if !cs.IsFrozen() { + t.Error("a client with a frozen height must report frozen") + } +} + +func TestClientStateDurations(t *testing.T) { + cs := validClientState() + if got := cs.TrustingPeriodDuration(); got != 14*24*time.Hour { + t.Errorf("trusting period = %s, want 336h", got) + } + if got := cs.MaxClockDriftDuration(); got != 10*time.Second { + t.Errorf("clock drift = %s, want 10s", got) + } +} + +// A header that has been verified becomes the next consensus state, and that +// consensus state must convert back into exactly the trusted state the core takes. +// This is the seam between the generated protos and the version-independent core, +// so a mistake here is silent. +func TestConsensusStateRoundTrip(t *testing.T) { + keys := genKeys(t, 3) + h := sealedHeaderAt(t, keys, 2, 77, 1_700_000_000) + + cs, err := types.NewConsensusState(h) + if err != nil { + t.Fatalf("NewConsensusState: %v", err) + } + if err := cs.ValidateBasic(); err != nil { + t.Fatalf("generated consensus state is invalid: %v", err) + } + + // Besu seconds must arrive as nanoseconds. + if cs.Timestamp != 1_700_000_000*uint64(time.Second) { + t.Errorf("timestamp = %d, want %d", cs.Timestamp, 1_700_000_000*uint64(time.Second)) + } + if cs.Root() != h.Root { + t.Errorf("state root = %s, want %s", cs.Root(), h.Root) + } + + trusted := cs.Trusted(77) + if trusted.Height != 77 { + t.Errorf("height = %d, want 77", trusted.Height) + } + if !trusted.Timestamp.Equal(time.Unix(1_700_000_000, 0).UTC()) { + t.Errorf("timestamp = %s, want %s", trusted.Timestamp, time.Unix(1_700_000_000, 0).UTC()) + } + + want := validatorsOf(keys) + if len(trusted.Validators) != len(want) { + t.Fatalf("got %d validators, want %d", len(trusted.Validators), len(want)) + } + for i := range want { + if trusted.Validators[i] != want[i] { + t.Errorf("validator %d = %s, want %s", i, trusted.Validators[i], want[i]) + } + } +} + +// The full loop the client runs each update: verify against the stored state, then +// store what the verified header carries. +func TestVerifyThenAdvance(t *testing.T) { + keys := genKeys(t, 4) + + genesis := sealedHeaderAt(t, keys, 3, 10, 1_000) + stored, err := types.NewConsensusState(genesis) + if err != nil { + t.Fatalf("NewConsensusState: %v", err) + } + + next := sealedHeaderAt(t, keys, 3, 11, 1_002) + if err := types.VerifyHeader( + stored.Trusted(10), next, time.Unix(1_002, 0).UTC(), + 14*24*time.Hour, 10*time.Second, + ); err != nil { + t.Fatalf("header should verify against the stored state: %v", err) + } + + advanced, err := types.NewConsensusState(next) + if err != nil { + t.Fatalf("NewConsensusState: %v", err) + } + if advanced.Root() != next.Root { + t.Errorf("advanced root = %s, want %s", advanced.Root(), next.Root) + } +} + +func TestConsensusStateValidate_Rejects(t *testing.T) { + base := func() *types.ConsensusState { + return &types.ConsensusState{ + Timestamp: 1, + StateRoot: common.Hash{}.Bytes(), + Validators: [][]byte{common.Address{}.Bytes()}, + } + } + + for name, mutate := range map[string]func(*types.ConsensusState){ + "zero timestamp": func(cs *types.ConsensusState) { cs.Timestamp = 0 }, + "short state root": func(cs *types.ConsensusState) { cs.StateRoot = []byte{0x01} }, + "no validators": func(cs *types.ConsensusState) { cs.Validators = nil }, + "short validator": func(cs *types.ConsensusState) { cs.Validators = [][]byte{{0x01}} }, + } { + t.Run(name, func(t *testing.T) { + cs := base() + mutate(cs) + if err := cs.ValidateBasic(); !errors.Is(err, types.ErrInvalidConsensusState) { + t.Errorf("want ErrInvalidConsensusState, got %v", err) + } + }) + } +} diff --git a/x/qbftclient/types/update.go b/x/qbftclient/types/update.go new file mode 100644 index 00000000..2e815d0c --- /dev/null +++ b/x/qbftclient/types/update.go @@ -0,0 +1,142 @@ +package types + +import ( + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +var ( + // ErrNotMonotonic is returned when a header does not advance past the trusted + // height. + ErrNotMonotonic = errors.New("qbft: header does not advance the client") + // ErrTimeRegression is returned when a header's timestamp is not after the + // trusted one. + ErrTimeRegression = errors.New("qbft: header timestamp moves backwards") + // ErrTrustingPeriodExpired is returned when too much counterparty time has + // passed since the trusted state for it to still vouch for a new header. + ErrTrustingPeriodExpired = errors.New("qbft: trusting period expired") + // ErrFromTheFuture is returned when a header's timestamp is further ahead of + // local time than the configured clock drift allows. + ErrFromTheFuture = errors.New("qbft: header timestamp beyond max clock drift") + // ErrNotSameHeight is returned when a misbehaviour submission carries headers + // at different heights, which is not evidence of anything. + ErrNotSameHeight = errors.New("qbft: misbehaviour headers are at different heights") + // ErrSameBlock is returned when a misbehaviour submission carries the same + // block twice. + ErrSameBlock = errors.New("qbft: misbehaviour headers are the same block") +) + +// TrustedState is what the client already believes: the height it has verified, +// that block's time, and the validator set entitled to seal the next one. +// +// This is the plain-Go shape of a stored consensus state. Keeping it free of +// proto and ibc-go types is what lets the verification logic outlive an ibc-go +// interface change. +type TrustedState struct { + Height uint64 + Timestamp time.Time + Validators []common.Address +} + +// VerifyHeader checks that h can be accepted on top of trusted. +// +// Order matters: the cheap structural checks run before signature recovery, so a +// malformed or stale submission cannot make the client do elliptic-curve work. +func VerifyHeader( + trusted TrustedState, + h *ethtypes.Header, + now time.Time, + trustingPeriod, maxClockDrift time.Duration, +) error { + if h.Number == nil { + return ErrNoNumber + } + + height := h.Number.Uint64() + if height <= trusted.Height { + return fmt.Errorf("%w: header %d is not above trusted %d", ErrNotMonotonic, height, trusted.Height) + } + + // Besu header timestamps are seconds since the epoch. + headerTime := time.Unix(int64(h.Time), 0).UTC() + if !headerTime.After(trusted.Timestamp) { + return fmt.Errorf("%w: header %s is not after trusted %s", + ErrTimeRegression, headerTime, trusted.Timestamp) + } + if headerTime.Sub(trusted.Timestamp) >= trustingPeriod { + return fmt.Errorf("%w: %s elapsed since the trusted state, limit %s", + ErrTrustingPeriodExpired, headerTime.Sub(trusted.Timestamp), trustingPeriod) + } + if headerTime.After(now.Add(maxClockDrift)) { + return fmt.Errorf("%w: header %s is beyond %s from %s", + ErrFromTheFuture, headerTime, maxClockDrift, now) + } + + return VerifyCommitSeals(h, trusted.Validators) +} + +// NextTrustedState returns the state to store once h has been verified: its +// height and time, and the validator set h itself carries. +// +// The set advances only here, after verification against the previous set, which +// is the whole of the chain-following rule. +func NextTrustedState(h *ethtypes.Header) (TrustedState, error) { + if h.Number == nil { + return TrustedState{}, ErrNoNumber + } + validators, err := HeaderValidators(h) + if err != nil { + return TrustedState{}, err + } + return TrustedState{ + Height: h.Number.Uint64(), + Timestamp: time.Unix(int64(h.Time), 0).UTC(), + Validators: validators, + }, nil +} + +// DetectMisbehaviour reports whether h1 and h2 are evidence that the +// counterparty's validators equivocated: the same height, both carrying a valid +// quorum of seals from the trusted set, but different block hashes. +// +// QBFT is instantly final, so two such blocks cannot both be legitimate — a +// quorum signed conflicting history. Returning an error rather than false for a +// malformed submission is deliberate: "these headers prove nothing" and "this +// submission is nonsense" are different answers, and a caller that conflates +// them would silently accept garbage as exoneration. +func DetectMisbehaviour(trusted TrustedState, h1, h2 *ethtypes.Header) (bool, error) { + if h1.Number == nil || h2.Number == nil { + return false, ErrNoNumber + } + if h1.Number.Cmp(h2.Number) != 0 { + return false, fmt.Errorf("%w: %d and %d", ErrNotSameHeight, h1.Number, h2.Number) + } + + hash1, err := BlockHash(h1) + if err != nil { + return false, err + } + hash2, err := BlockHash(h2) + if err != nil { + return false, err + } + if hash1 == hash2 { + return false, fmt.Errorf("%w: %s", ErrSameBlock, hash1) + } + + // Both must be genuinely sealed by the trusted set. One valid header beside a + // forgery is not misbehaviour, and freezing on it would let anyone disable the + // corridor with a fabricated second header. + if err := VerifyCommitSeals(h1, trusted.Validators); err != nil { + return false, fmt.Errorf("qbft: first misbehaviour header: %w", err) + } + if err := VerifyCommitSeals(h2, trusted.Validators); err != nil { + return false, fmt.Errorf("qbft: second misbehaviour header: %w", err) + } + + return true, nil +} diff --git a/x/qbftclient/types/update_test.go b/x/qbftclient/types/update_test.go new file mode 100644 index 00000000..c9e7024a --- /dev/null +++ b/x/qbftclient/types/update_test.go @@ -0,0 +1,238 @@ +package types_test + +import ( + "crypto/ecdsa" + "errors" + "testing" + "time" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +const ( + trustingPeriod = 14 * 24 * time.Hour // DEC-8 + maxClockDrift = 10 * time.Second +) + +func TestVerifyHeader_Accepts(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{ + Height: 10, + Timestamp: time.Unix(1_000, 0).UTC(), + Validators: validatorsOf(keys), + } + h := sealedHeaderAt(t, keys, 3, 11, 1_002) + + if err := types.VerifyHeader(trusted, h, time.Unix(1_002, 0).UTC(), trustingPeriod, maxClockDrift); err != nil { + t.Errorf("header should verify, got %v", err) + } +} + +func TestVerifyHeader_HeightMustAdvance(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{ + Height: 11, + Timestamp: time.Unix(1_000, 0).UTC(), + Validators: validatorsOf(keys), + } + // Same height as trusted: a replay, not an update. + h := sealedHeaderAt(t, keys, 3, 11, 1_002) + + err := types.VerifyHeader(trusted, h, time.Unix(1_002, 0).UTC(), trustingPeriod, maxClockDrift) + if !errors.Is(err, types.ErrNotMonotonic) { + t.Errorf("want ErrNotMonotonic, got %v", err) + } +} + +func TestVerifyHeader_TimeMustAdvance(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{ + Height: 10, + Timestamp: time.Unix(2_000, 0).UTC(), + Validators: validatorsOf(keys), + } + h := sealedHeaderAt(t, keys, 3, 11, 1_999) + + err := types.VerifyHeader(trusted, h, time.Unix(2_100, 0).UTC(), trustingPeriod, maxClockDrift) + if !errors.Is(err, types.ErrTimeRegression) { + t.Errorf("want ErrTimeRegression, got %v", err) + } +} + +// The trusting period is what a relayer heartbeat must beat; past it the trusted +// state can no longer vouch for anything. +func TestVerifyHeader_TrustingPeriodExpired(t *testing.T) { + keys := genKeys(t, 4) + start := uint64(1_000) + trusted := types.TrustedState{ + Height: 10, + Timestamp: time.Unix(int64(start), 0).UTC(), + Validators: validatorsOf(keys), + } + elapsed := uint64(trustingPeriod/time.Second) + 1 + h := sealedHeaderAt(t, keys, 3, 11, start+elapsed) + + err := types.VerifyHeader(trusted, h, time.Unix(int64(start+elapsed), 0).UTC(), trustingPeriod, maxClockDrift) + if !errors.Is(err, types.ErrTrustingPeriodExpired) { + t.Errorf("want ErrTrustingPeriodExpired, got %v", err) + } +} + +func TestVerifyHeader_BeyondClockDrift(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{ + Height: 10, + Timestamp: time.Unix(1_000, 0).UTC(), + Validators: validatorsOf(keys), + } + // Header claims a time well past local now + drift. + h := sealedHeaderAt(t, keys, 3, 11, 1_100) + + err := types.VerifyHeader(trusted, h, time.Unix(1_010, 0).UTC(), trustingPeriod, maxClockDrift) + if !errors.Is(err, types.ErrFromTheFuture) { + t.Errorf("want ErrFromTheFuture, got %v", err) + } +} + +// A header sealed by a set the client does not trust must fail even when every +// structural check passes. +func TestVerifyHeader_UntrustedSigners(t *testing.T) { + keys := genKeys(t, 4) + strangers := genKeys(t, 4) + trusted := types.TrustedState{ + Height: 10, + Timestamp: time.Unix(1_000, 0).UTC(), + Validators: validatorsOf(strangers), + } + h := sealedHeaderAt(t, keys, 3, 11, 1_002) + + err := types.VerifyHeader(trusted, h, time.Unix(1_002, 0).UTC(), trustingPeriod, maxClockDrift) + if !errors.Is(err, types.ErrUnknownCommitter) { + t.Errorf("want ErrUnknownCommitter, got %v", err) + } +} + +// The set advances only after verification: NextTrustedState reads the set the +// verified header carries, which becomes the set trusted for the following height. +func TestNextTrustedState(t *testing.T) { + keys := genKeys(t, 3) + h := sealedHeaderAt(t, keys, 2, 42, 5_000) + + next, err := types.NextTrustedState(h) + if err != nil { + t.Fatalf("NextTrustedState: %v", err) + } + if next.Height != 42 { + t.Errorf("height = %d, want 42", next.Height) + } + if !next.Timestamp.Equal(time.Unix(5_000, 0).UTC()) { + t.Errorf("timestamp = %s, want %s", next.Timestamp, time.Unix(5_000, 0).UTC()) + } + want := validatorsOf(keys) + if len(next.Validators) != len(want) { + t.Fatalf("got %d validators, want %d", len(next.Validators), len(want)) + } + for i := range want { + if next.Validators[i] != want[i] { + t.Errorf("validator %d = %s, want %s", i, next.Validators[i], want[i]) + } + } +} + +// Rotation end to end, which is the whole of DEC-10's chain-following rule: a +// header sealed by the outgoing set may announce a different one, and that +// announced set — not the old one — verifies the next header. +func TestValidatorSetRotation(t *testing.T) { + oldKeys := genKeys(t, 4) + // Two validators leave, two join. + newKeys := append(append([]*ecdsa.PrivateKey{}, oldKeys[:2]...), genKeys(t, 2)...) + + trusted := types.TrustedState{ + Height: 10, + Timestamp: time.Unix(1_000, 0).UTC(), + Validators: validatorsOf(oldKeys), + } + + // The rotation block: sealed by the OLD set, carrying the NEW one. + rotation := sealedHeaderCarrying(t, oldKeys, validatorsOf(newKeys), 3, 11, 1_002) + if err := types.VerifyHeader(trusted, rotation, time.Unix(1_002, 0).UTC(), trustingPeriod, maxClockDrift); err != nil { + t.Fatalf("rotation header should verify against the old set: %v", err) + } + + next, err := types.NextTrustedState(rotation) + if err != nil { + t.Fatalf("NextTrustedState: %v", err) + } + if len(next.Validators) != len(newKeys) || next.Validators[3] != validatorsOf(newKeys)[3] { + t.Fatalf("trusted set did not advance to the carried set") + } + + // The next header is sealed by the NEW set and must verify. + follow := sealedHeaderAt(t, newKeys, 3, 12, 1_004) + if err := types.VerifyHeader(next, follow, time.Unix(1_004, 0).UTC(), trustingPeriod, maxClockDrift); err != nil { + t.Errorf("header sealed by the rotated-in set should verify: %v", err) + } + + // The departed validators can no longer carry a header on their own: only two + // of them remain in the new set, which is below its quorum of three. + departed := sealedHeaderAt(t, oldKeys[2:], 2, 12, 1_004) + if err := types.VerifyHeader(next, departed, time.Unix(1_004, 0).UTC(), trustingPeriod, maxClockDrift); err == nil { + t.Error("a header sealed only by rotated-out validators must not verify") + } +} + +func TestDetectMisbehaviour(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{Height: 10, Validators: validatorsOf(keys)} + + // Same height, both properly sealed, different block times -- so different + // block hashes. QBFT is instantly final, so this is equivocation. + h1 := sealedHeaderAt(t, keys, 3, 11, 1_002) + h2 := sealedHeaderAt(t, keys, 3, 11, 1_003) + + got, err := types.DetectMisbehaviour(trusted, h1, h2) + if err != nil { + t.Fatalf("DetectMisbehaviour: %v", err) + } + if !got { + t.Error("two validly-sealed conflicting headers should be misbehaviour") + } +} + +func TestDetectMisbehaviour_DifferentHeights(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{Height: 10, Validators: validatorsOf(keys)} + + _, err := types.DetectMisbehaviour(trusted, + sealedHeaderAt(t, keys, 3, 11, 1_002), + sealedHeaderAt(t, keys, 3, 12, 1_004)) + if !errors.Is(err, types.ErrNotSameHeight) { + t.Errorf("want ErrNotSameHeight, got %v", err) + } +} + +func TestDetectMisbehaviour_SameBlock(t *testing.T) { + keys := genKeys(t, 4) + trusted := types.TrustedState{Height: 10, Validators: validatorsOf(keys)} + h := sealedHeaderAt(t, keys, 3, 11, 1_002) + + _, err := types.DetectMisbehaviour(trusted, h, h) + if !errors.Is(err, types.ErrSameBlock) { + t.Errorf("want ErrSameBlock, got %v", err) + } +} + +// A forged second header must not freeze the client: otherwise anyone could +// disable the corridor by inventing a conflicting block. +func TestDetectMisbehaviour_ForgedSecondHeader(t *testing.T) { + keys := genKeys(t, 4) + forgers := genKeys(t, 4) + trusted := types.TrustedState{Height: 10, Validators: validatorsOf(keys)} + + _, err := types.DetectMisbehaviour(trusted, + sealedHeaderAt(t, keys, 3, 11, 1_002), + sealedHeaderAt(t, forgers, 3, 11, 1_003)) + if !errors.Is(err, types.ErrUnknownCommitter) { + t.Errorf("want the forged header rejected, got %v", err) + } +} diff --git a/x/qbftclient/types/verify.go b/x/qbftclient/types/verify.go new file mode 100644 index 00000000..bd896230 --- /dev/null +++ b/x/qbftclient/types/verify.go @@ -0,0 +1,85 @@ +package types + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +var ( + // ErrUnknownCommitter is returned when a committed seal recovers to an address + // outside the trusted validator set. + ErrUnknownCommitter = errors.New("qbft: committed seal from outside the validator set") + // ErrDuplicateSeal is returned when one validator seals the same header twice. + ErrDuplicateSeal = errors.New("qbft: duplicate committed seal") + // ErrQuorumNotMet is returned when a header carries fewer distinct seals than + // QBFT finality requires. + ErrQuorumNotMet = errors.New("qbft: committed seals below quorum") +) + +// RequiredQuorum returns the number of distinct committed seals a QBFT header must +// carry to be final: ceil(2n/3) of the validator set. +// +// Besu: BftHelpers.calculateRequiredValidatorQuorum — Util.fastDivCeiling(2 * validatorCount, 3). +// The ceiling is expressed as (2n+2)/3 in integer arithmetic. +func RequiredQuorum(validatorCount int) int { + if validatorCount <= 0 { + return 0 + } + return (2*validatorCount + 2) / 3 +} + +// VerifyCommitSeals checks that h carries a quorum of committed seals from distinct +// members of validators. +// +// validators is the set trusted *for this height*. Under the chain-following model +// that is the set carried by the last verified header, never the set carried by h — +// a header cannot authorise the validator set that vouches for it. +func VerifyCommitSeals(h *ethtypes.Header, validators []common.Address) error { + if len(validators) == 0 { + return ErrNoValidators + } + + committers, err := RecoverCommitters(h) + if err != nil { + return err + } + + trusted := make(map[common.Address]struct{}, len(validators)) + for _, v := range validators { + trusted[v] = struct{}{} + } + + seen := make(map[common.Address]struct{}, len(committers)) + for _, c := range committers { + if _, ok := trusted[c]; !ok { + return fmt.Errorf("%w: %s", ErrUnknownCommitter, c) + } + if _, ok := seen[c]; ok { + return fmt.Errorf("%w: %s", ErrDuplicateSeal, c) + } + seen[c] = struct{}{} + } + + if quorum := RequiredQuorum(len(validators)); len(seen) < quorum { + return fmt.Errorf("%w: %d of %d, need %d", ErrQuorumNotMet, len(seen), len(validators), quorum) + } + return nil +} + +// HeaderValidators returns the validator set the header itself carries. Once h has +// been verified against the previously trusted set, this becomes the set trusted for +// the next height — which is how the client follows QBFT set changes without a +// client migration. +func HeaderValidators(h *ethtypes.Header) ([]common.Address, error) { + e, err := DecodeExtraData(h.Extra) + if err != nil { + return nil, err + } + if len(e.Validators) == 0 { + return nil, ErrNoValidators + } + return e.Validators, nil +} diff --git a/x/qbftclient/types/verify_test.go b/x/qbftclient/types/verify_test.go new file mode 100644 index 00000000..4dd2949f --- /dev/null +++ b/x/qbftclient/types/verify_test.go @@ -0,0 +1,160 @@ +package types_test + +import ( + "crypto/ecdsa" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// TestRequiredQuorum pins the threshold to Besu's, since a divergence here either +// accepts headers Besu considers unfinal or rejects ones it finalised. +// +// Besu: BftHelpers.calculateRequiredValidatorQuorum = fastDivCeiling(2n, 3). +func TestRequiredQuorum(t *testing.T) { + for _, tc := range []struct{ n, want int }{ + {0, 0}, {1, 1}, {2, 2}, {3, 2}, {4, 3}, {5, 4}, {6, 4}, {7, 5}, {10, 7}, {21, 14}, + } { + if got := types.RequiredQuorum(tc.n); got != tc.want { + t.Errorf("RequiredQuorum(%d) = %d, want %d", tc.n, got, tc.want) + } + } +} + +// The header builders live in x/qbftclient/testutil: the light-client tests, the +// integration tests and (next) the proof constructor all need identical material, +// and a second copy of these encodings is exactly the divergence extradata.go warns +// about. These are thin aliases so the call sites below stay readable. + +func genKeys(t *testing.T, n int) []*ecdsa.PrivateKey { return qbfttestutil.Keys(t, n) } + +func validatorsOf(keys []*ecdsa.PrivateKey) []common.Address { return qbfttestutil.Validators(keys) } + +func sealedHeader(t *testing.T, keys []*ecdsa.PrivateKey, sealCount int) *ethtypes.Header { + return sealedHeaderAt(t, keys, sealCount, 1, 0) +} + +func sealedHeaderAt(t *testing.T, keys []*ecdsa.PrivateKey, sealCount int, height, blockTime uint64) *ethtypes.Header { + return qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: height, Time: blockTime, Seals: sealCount, + }) +} + +func sealedHeaderCarrying( + t *testing.T, + signers []*ecdsa.PrivateKey, + carried []common.Address, + sealCount int, + height, blockTime uint64, +) *ethtypes.Header { + return qbfttestutil.SealedHeader(t, signers, qbfttestutil.HeaderOpts{ + Height: height, Time: blockTime, Seals: sealCount, Carried: carried, + }) +} + +func TestVerifyCommitSeals_QuorumBoundary(t *testing.T) { + keys := genKeys(t, 4) // quorum is 3 + validators := validatorsOf(keys) + + if err := types.VerifyCommitSeals(sealedHeader(t, keys, 3), validators); err != nil { + t.Errorf("3 of 4 seals should meet quorum, got %v", err) + } + + err := types.VerifyCommitSeals(sealedHeader(t, keys, 2), validators) + if !errors.Is(err, types.ErrQuorumNotMet) { + t.Errorf("2 of 4 seals should fail quorum, got %v", err) + } +} + +// A single validator is the state a freshly founded spoke starts in — the toolkit +// generates one node — so it must verify rather than trip an edge case. +func TestVerifyCommitSeals_SingleValidator(t *testing.T) { + keys := genKeys(t, 1) + if err := types.VerifyCommitSeals(sealedHeader(t, keys, 1), validatorsOf(keys)); err != nil { + t.Errorf("single-validator header should verify, got %v", err) + } +} + +func TestVerifyCommitSeals_UnknownCommitter(t *testing.T) { + keys := genKeys(t, 4) + h := sealedHeader(t, keys, 3) + + // Trust a set that excludes one of the actual signers. + trusted := append(validatorsOf(keys[:2]), validatorsOf(genKeys(t, 1))[0], common.Address{0x01}) + + if err := types.VerifyCommitSeals(h, trusted); !errors.Is(err, types.ErrUnknownCommitter) { + t.Errorf("seal from outside the set should be rejected, got %v", err) + } +} + +func TestVerifyCommitSeals_DuplicateSeal(t *testing.T) { + keys := genKeys(t, 4) + h := sealedHeader(t, keys, 3) + + extra, err := types.DecodeExtraData(h.Extra) + if err != nil { + t.Fatalf("decode: %v", err) + } + // Replace the third seal with a copy of the first: still three seals, but only + // two distinct validators. + extra.Seals[2] = extra.Seals[0] + if h.Extra, err = extra.Encode(); err != nil { + t.Fatalf("encode: %v", err) + } + + if err := types.VerifyCommitSeals(h, validatorsOf(keys)); !errors.Is(err, types.ErrDuplicateSeal) { + t.Errorf("duplicate seal should be rejected, got %v", err) + } +} + +func TestVerifyCommitSeals_EmptyValidatorSet(t *testing.T) { + keys := genKeys(t, 1) + if err := types.VerifyCommitSeals(sealedHeader(t, keys, 1), nil); !errors.Is(err, types.ErrNoValidators) { + t.Errorf("empty trusted set should be rejected, got %v", err) + } +} + +// HeaderValidators is what makes the set follow the chain: the set a verified header +// carries becomes the set trusted for the next one. +func TestHeaderValidators(t *testing.T) { + keys := genKeys(t, 3) + got, err := types.HeaderValidators(sealedHeader(t, keys, 2)) + if err != nil { + t.Fatalf("HeaderValidators: %v", err) + } + + want := validatorsOf(keys) + if len(got) != len(want) { + t.Fatalf("got %d validators, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("validator %d = %s, want %s", i, got[i], want[i]) + } + } +} + +// Decoding and re-encoding an untouched header must be byte-identical, or every +// hash derived from it diverges from Besu's. +func TestExtraDataRoundTrip(t *testing.T) { + keys := genKeys(t, 4) + original := sealedHeader(t, keys, 3).Extra + + extra, err := types.DecodeExtraData(original) + if err != nil { + t.Fatalf("decode: %v", err) + } + reencoded, err := extra.Encode() + if err != nil { + t.Fatalf("encode: %v", err) + } + + if string(reencoded) != string(original) { + t.Errorf("round trip changed extraData:\n got %x\nwant %x", reencoded, original) + } +} From 4087501da619a021bcb8a8461ef4fcb1a7ff683a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 08:30:42 +0200 Subject: [PATCH 06/61] feat(qbftclient): prover, qbftinit and qbftrelay The proof constructor shares the client's verification core, so the relayer serialises exactly what the client deserialises and the two cannot diverge. Nothing here signs: every tool emits unsigned transactions for the operator's own tooling, which keeps key custody out of the relaying path. - prover/besu reads a real Besu node over JSON-RPC; prover/cosmos produces the ICS-23 proofs for the return leg (cmd/v2relay is refactored onto it in a later commit). - UpdateChain assembles the header chain needed to cross a validator-set change. Besu does not confine set changes to epoch boundaries -- an epoch block discards outstanding votes, it is not where changes take effect -- so the relayer cannot skip one. It binary-searches for change points rather than walking block by block. - qbftinit emits an unsigned MsgCreateClient and prints what is being trusted, warning when the set is below the BFT threshold. The initial trusted state is the one input a light client cannot verify, so it belongs in front of the operator rather than buried in a JSON blob. - qbftrelay is one-shot: client updates plus a packet proof, no event loop, no retries, no state. besu.HeaderByNumber re-hashes every reassembled header with our own QBFT block-hash implementation and compares it against the hash the node reported. JSON-RPC serves headers as fields, not RLP, so a field the node populates and go-ethereum ignores would yield a header that decodes cleanly and verifies against nothing. This stops that at the RPC boundary instead of as an unexplainable on-chain signature failure. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/qbftinit/main.go | 169 ++++++++++++ cmd/qbftrelay/main.go | 180 +++++++++++++ tests/integration/qbft_live_test.go | 217 +++++++++++++++ tests/integration/qbft_pilot_test.go | 176 ++++++++++++ x/qbftclient/prover/besu/client.go | 146 ++++++++++ x/qbftclient/prover/besu/client_test.go | 132 +++++++++ x/qbftclient/prover/besu/live_storage_test.go | 185 +++++++++++++ x/qbftclient/prover/besu/live_test.go | 158 +++++++++++ x/qbftclient/prover/cosmos/prover.go | 195 ++++++++++++++ x/qbftclient/prover/cosmos/prover_test.go | 217 +++++++++++++++ x/qbftclient/prover/msgs/msgs.go | 129 +++++++++ x/qbftclient/prover/msgs/msgs_test.go | 190 +++++++++++++ x/qbftclient/prover/prover.go | 252 ++++++++++++++++++ x/qbftclient/prover/prover_test.go | 234 ++++++++++++++++ 14 files changed, 2580 insertions(+) create mode 100644 cmd/qbftinit/main.go create mode 100644 cmd/qbftrelay/main.go create mode 100644 tests/integration/qbft_live_test.go create mode 100644 tests/integration/qbft_pilot_test.go create mode 100644 x/qbftclient/prover/besu/client.go create mode 100644 x/qbftclient/prover/besu/client_test.go create mode 100644 x/qbftclient/prover/besu/live_storage_test.go create mode 100644 x/qbftclient/prover/besu/live_test.go create mode 100644 x/qbftclient/prover/cosmos/prover.go create mode 100644 x/qbftclient/prover/cosmos/prover_test.go create mode 100644 x/qbftclient/prover/msgs/msgs.go create mode 100644 x/qbftclient/prover/msgs/msgs_test.go create mode 100644 x/qbftclient/prover/prover.go create mode 100644 x/qbftclient/prover/prover_test.go diff --git a/cmd/qbftinit/main.go b/cmd/qbftinit/main.go new file mode 100644 index 00000000..55b203ee --- /dev/null +++ b/cmd/qbftinit/main.go @@ -0,0 +1,169 @@ +// 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 cbdcd, 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 + } + + 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) + 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/qbftrelay/main.go b/cmd/qbftrelay/main.go new file mode 100644 index 00000000..5ffe9f3a --- /dev/null +++ b/cmd/qbftrelay/main.go @@ -0,0 +1,180 @@ +// 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 cbdcd: +// +// cbdcd tx sign unsigned.json --from alice ... --output-document signed.json +// cbdcd 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" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/msgs" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +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") + 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) + } + + cfg := config{ + besuRPC: *besuRPC, + clientID: *clientID, + contract: common.HexToAddress(*contract), + trusted: *trustedAt, + target: *targetAt, + signer: *signer, + evmChain: *evmChain, + gasLimit: *gasLimit, + out: *out, + } + + 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 +} + +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() + + // The set the client trusts is the set carried by the header it last verified, + // so it is read from the counterparty rather than passed in — one fewer flag to + // get wrong, and it cannot disagree with the chain. + trustedHeader, err := chain.HeaderByNumber(ctx, cfg.trusted) + if err != nil { + return err + } + trustedSet, err := types.HeaderValidators(trustedHeader) + if err != nil { + return err + } + + p := prover.New(chain, cfg.contract) + + headers, err := p.UpdateChain(ctx, trustedSet, cfg.trusted, cfg.target) + if err != nil { + return err + } + updates, err := msgs.UpdateClientChain(cfg.clientID, headers, cfg.signer) + if err != nil { + return err + } + + proof, err := p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, cfg.target) + if err != nil { + return err + } + recv, err := msgs.RecvPacket(encCfg.Codec, packet, proof, cfg.target, cfg.signer) + if err != nil { + return err + } + + // 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(headers) > 1 { + fmt.Printf("updates %d headers (the validator set changed in this range)\n", len(headers)) + } else { + fmt.Printf("updates 1 header\n") + } + fmt.Printf("wrote unsigned tx to %s\n", cfg.out) + return nil +} diff --git a/tests/integration/qbft_live_test.go b/tests/integration/qbft_live_test.go new file mode 100644 index 00000000..05115175 --- /dev/null +++ b/tests/integration/qbft_live_test.go @@ -0,0 +1,217 @@ +package integration + +import ( + "context" + "crypto/ecdsa" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + 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" + commitmenttypesv2 "github.com/cosmos/ibc-go/v10/modules/core/23-commitment/types/v2" + + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + "github.com/peersyst/cbdc-node/x/qbftclient/prover" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + qbfttypes "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// storageWriterCode is a contract whose entire behaviour is +// `sstore(calldata[0:32], calldata[32:64])` — enough to place a real packet +// commitment at the slot solidity-ibc-eureka would use, without needing Foundry to +// build the real contracts. +const storageWriterCode = "0x6008600c60003960086000f36020356000355500" + +var liveChainID = big.NewInt(1338) + +// TestLive_InboundLegEndToEnd is the closest thing to the pilot that can be run +// without the counterparty's contracts: a real packet commitment written into a +// real Besu chain, proved by the real prover, and accepted by cbdc-node's real +// ClientKeeper. +// +// Everything crossing the boundary here is genuine — Besu's headers and committed +// seals, its Merkle-Patricia proofs, our client's verification path. What is +// simulated is only *who wrote the commitment*: a 20-byte stand-in rather than +// ICS20Transfer. The bytes at the slot are identical either way, which is the whole +// point of deriving the slot rather than being told it. +// +// QBFT_BESU_RPC=http://127.0.0.1:8645 \ +// QBFT_BESU_KEY= \ +// go test -tags=test ./tests/integration/ -run TestLive_InboundLegEndToEnd +func TestLive_InboundLegEndToEnd(t *testing.T) { + rpcURL, keyHex := os.Getenv("QBFT_BESU_RPC"), os.Getenv("QBFT_BESU_KEY") + if rpcURL == "" || keyHex == "" { + t.Skip("QBFT_BESU_RPC and QBFT_BESU_KEY not set; skipping live end-to-end") + } + + cbdccommon.SetupSdkConfig() + ctx := context.Background() + + rpcClient, err := rpc.DialContext(ctx, rpcURL) + require.NoError(t, err) + defer rpcClient.Close() + + key, err := crypto.HexToECDSA(keyHex) + require.NoError(t, err) + from := crypto.PubkeyToAddress(key.PublicKey) + + chain := besu.New(rpcClient) + + // --- counterparty side: write a real packet commitment ------------------ + + contract := liveDeploy(t, ctx, rpcClient, key, from) + + packet := channeltypesv2.Packet{ + Sequence: 1, + SourceClient: "qbft-0", + DestinationClient: "07-tendermint-0", + TimeoutTimestamp: uint64(time.Now().Add(time.Hour).Unix()), + Payloads: []channeltypesv2.Payload{{ + SourcePort: "transfer", + DestinationPort: "transfer", + Version: "ics20-1", + Encoding: "application/x-solidity-abi", + Value: []byte("live-end-to-end"), + }}, + } + commitment := channeltypesv2.CommitPacket(packet) + require.Len(t, commitment, 32) + + slot := qbfttypes.PacketCommitmentSlot(packet.SourceClient, packet.Sequence) + writtenAt := liveSend(t, ctx, rpcClient, key, from, &contract, append(slot.Bytes(), commitment...)) + t.Logf("commitment written at block %d, contract %s", writtenAt, contract) + + // Trust a height before the write, so the client has to be advanced to reach it. + trustedHeight := writtenAt - 1 + trustedHeader, err := chain.HeaderByNumber(ctx, trustedHeight) + require.NoError(t, err, "fetching the trusted header") + trustedSet, err := qbfttypes.HeaderValidators(trustedHeader) + require.NoError(t, err) + + // --- cbdc-node side: create the client, advance it, verify the proof ---- + + _, chains := cbdcintegration.NewIBCCoordinator(t, 1) + testChain := chains[0] + a := testChain.App.(*app.App) + cdc := a.AppCodec() + k := a.GetIBCKeeper().ClientKeeper + sdkCtx := testChain.GetContext() + + consensusState, err := qbfttypes.NewConsensusState(trustedHeader) + require.NoError(t, err) + + // The clock bounds are deliberately wide. The test harness's block time and + // Besu's wall clock are unrelated, and what is under test here is byte + // agreement, not time policy — which VerifyHeader's own unit tests cover. + clientStateBz, err := cdc.Marshal(&qbfttypes.ClientState{ + ChainId: uint64(liveChainID.Int64()), + TrustingPeriod: uint64((100 * 365 * 24 * time.Hour) / time.Second), + MaxClockDrift: uint64((100 * 365 * 24 * time.Hour) / time.Second), + LatestHeight: trustedHeight, + IbcContractAddress: contract.Bytes(), + }) + require.NoError(t, err) + consensusStateBz, err := cdc.Marshal(consensusState) + require.NoError(t, err) + + clientID, err := k.CreateClient(sdkCtx, qbfttypes.ClientType, clientStateBz, consensusStateBz) + require.NoError(t, err, "creating a client from a real Besu header") + + // Advance the client with real headers, exactly as cmd/qbftrelay would. + p := prover.New(chain, contract) + headers, err := p.UpdateChain(ctx, trustedSet, trustedHeight, writtenAt) + require.NoError(t, err, "assembling the update chain") + require.NotEmpty(t, headers) + + for i, h := range headers { + require.NoError(t, k.UpdateClient(sdkCtx, clientID, h), + "real header %d of %d must be accepted by the client", i+1, len(headers)) + } + require.Equal(t, clienttypes.NewHeight(0, writtenAt), k.GetClientLatestHeight(sdkCtx, clientID), + "the client must reach the height the commitment was written at") + + // The proof, produced by the real prover from real chain state. + storageProof, err := p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, writtenAt) + require.NoError(t, err, "producing the commitment proof") + proofBz, err := cdc.Marshal(storageProof) + require.NoError(t, err) + + path := commitmenttypesv2.NewMerklePath( + qbfttypes.PacketCommitmentPath(packet.SourceClient, packet.Sequence)) + + // This is the assertion the whole corridor rests on. + require.NoError(t, + k.VerifyMembership(sdkCtx, clientID, clienttypes.NewHeight(0, writtenAt), 0, 0, proofBz, path, commitment), + "a real commitment, proved from a real chain, must verify through the real client") + + // And the negative: the same proof must not authenticate a different packet. + other := packet + other.TimeoutTimestamp++ + require.Error(t, + k.VerifyMembership(sdkCtx, clientID, clienttypes.NewHeight(0, writtenAt), 0, 0, proofBz, path, + channeltypesv2.CommitPacket(other)), + "a proof must not authenticate a packet other than the one committed") +} + +func liveDeploy(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address) common.Address { + t.Helper() + + code, err := hexutil.Decode(storageWriterCode) + require.NoError(t, err) + liveSend(t, ctx, c, key, from, nil, code) + + nonce := liveNonce(t, ctx, c, from) + return crypto.CreateAddress(from, nonce-1) +} + +func liveSend(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address, to *common.Address, data []byte) uint64 { + t.Helper() + + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: liveNonce(t, ctx, c, from), + To: to, + Gas: 1_000_000, + GasPrice: big.NewInt(0), + Data: data, + }) + signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(liveChainID), key) + require.NoError(t, err) + raw, err := signed.MarshalBinary() + require.NoError(t, err) + + var hash common.Hash + require.NoError(t, c.CallContext(ctx, &hash, "eth_sendRawTransaction", hexutil.Encode(raw))) + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + var receipt struct { + BlockNumber hexutil.Uint64 `json:"blockNumber"` + Status hexutil.Uint64 `json:"status"` + } + if err := c.CallContext(ctx, &receipt, "eth_getTransactionReceipt", hash); err == nil && receipt.BlockNumber != 0 { + require.EqualValues(t, 1, receipt.Status, "transaction reverted") + return uint64(receipt.BlockNumber) + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("transaction %s was not mined in time", hash) + return 0 +} + +func liveNonce(t *testing.T, ctx context.Context, c *rpc.Client, addr common.Address) uint64 { + t.Helper() + var n hexutil.Uint64 + require.NoError(t, c.CallContext(ctx, &n, "eth_getTransactionCount", addr, "pending")) + return uint64(n) +} diff --git a/tests/integration/qbft_pilot_test.go b/tests/integration/qbft_pilot_test.go new file mode 100644 index 00000000..50b2fa36 --- /dev/null +++ b/tests/integration/qbft_pilot_test.go @@ -0,0 +1,176 @@ +package integration + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + sdkmath "cosmossdk.io/math" + sdktypes "github.com/cosmos/cosmos-sdk/types" + + ratelimittypes "github.com/cosmos/ibc-apps/modules/rate-limiting/v10/types" + transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" + clientv2types "github.com/cosmos/ibc-go/v10/modules/core/02-client/v2/types" + + "github.com/peersyst/cbdc-node/app" + cbdccommon "github.com/peersyst/cbdc-node/testutil/integration/cbdc/common" + cbdcintegration "github.com/peersyst/cbdc-node/testutil/integration/cbdc/integration" + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + qbfttypes "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// The merkle prefix the counterparty must be registered with. "aWJj" is base64 for +// "ibc", the store name; the empty second element is the path within it. +// +// Runbook T8.3 / §2.18: a one-element prefix is accepted at registration, fails on +// the first receive, and the counterparty cannot be re-registered — so recovery +// means a new client, and under DEC-5 a new client id means a new escrow address +// and a new voucher denom. It is the most expensive silent mistake in the deploy. +var ( + correctPrefix = [][]byte{[]byte("ibc"), {}} + brokenPrefix = [][]byte{[]byte("ibc")} +) + +// TestQBFTPilot_DeploySteps rehearses the cbdc-node half of the pilot deployment — +// build-order steps 5.4 to 5.6 — against the real keepers. +// +// It exists because each of these steps fails silently in a different way: a wrong +// merkle prefix surfaces only on the first receive, an eth-derived relayer address +// simply never matches, and missing rate limits leave outflows unbounded with no +// error anywhere. Rehearsing them turns three silent traps into assertions. +func TestQBFTPilot_DeploySteps(t *testing.T) { + cbdccommon.SetupSdkConfig() + + _, chains := cbdcintegration.NewIBCCoordinator(t, 1) + chain := chains[0] + a := chain.App.(*app.App) + cdc := a.AppCodec() + ctx := chain.GetContext() + + k := a.GetIBCKeeper().ClientKeeper + kv2 := a.GetIBCKeeper().ClientV2Keeper + + // 5.4a — create the QBFT client, as cmd/qbftinit would. + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, qbftContract, map[common.Hash][]byte{}) + now := uint64(ctx.BlockTime().Unix()) + + genesis := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ + Height: 100, Time: now - 4, Seals: 3, StateRoot: state.Root, + }) + consensusState, err := qbfttypes.NewConsensusState(genesis) + require.NoError(t, err) + + clientStateBz, err := cdc.Marshal(&qbfttypes.ClientState{ + ChainId: qbftChainID, + TrustingPeriod: qbftTrustingPeriod, + MaxClockDrift: qbftClockDrift, + LatestHeight: 100, + IbcContractAddress: qbftContract.Bytes(), + }) + require.NoError(t, err) + consensusStateBz, err := cdc.Marshal(consensusState) + require.NoError(t, err) + + clientID, err := k.CreateClient(ctx, qbfttypes.ClientType, clientStateBz, consensusStateBz) + require.NoError(t, err, "5.4a: MsgCreateClient") + + t.Run("5.4b counterparty registered with the two-element merkle prefix", func(t *testing.T) { + const counterpartyClientID = "qbft-0" // the client of cbdc-node, on the spoke + + kv2.SetClientCounterparty(ctx, clientID, + clientv2types.NewCounterpartyInfo(correctPrefix, counterpartyClientID)) + + got, ok := kv2.GetClientCounterparty(ctx, clientID) + require.True(t, ok, "counterparty must be registered") + require.Equal(t, counterpartyClientID, got.ClientId) + + // This is the assertion the deploy checklist turns on. Anything other than + // exactly ["aWJj", ""] is unrecoverable once packets start flowing. + require.Len(t, got.MerklePrefix, 2, + "merkle prefix must have two elements — a one-element prefix fails on first receive and cannot be re-registered") + require.Equal(t, []byte("ibc"), got.MerklePrefix[0]) + require.Empty(t, got.MerklePrefix[1]) + }) + + t.Run("5.4b the broken one-element prefix is accepted at registration", func(t *testing.T) { + // Recorded, not fixed: the point is that nothing stops it here. Registration + // succeeds and the mistake is invisible until the first receive, which is + // precisely why it has to be checked at deploy time. + other, err := k.CreateClient(ctx, qbfttypes.ClientType, clientStateBz, consensusStateBz) + require.NoError(t, err) + + kv2.SetClientCounterparty(ctx, other, clientv2types.NewCounterpartyInfo(brokenPrefix, "qbft-0")) + + got, ok := kv2.GetClientCounterparty(ctx, other) + require.True(t, ok) + require.Len(t, got.MerklePrefix, 1, + "the chain accepts a one-element prefix — only the deploy check catches it") + }) + + t.Run("5.5 allowed_relayers takes a cosmos bech32 address", func(t *testing.T) { + // §2.19.5: the allowlist gates MsgUpdateClient, so whatever performs the + // T8.7 heartbeat must be on it — and the entry must be the signer's cosmos + // secp256k1 bech32 address. An eth-derived address is a different string and + // simply never matches; nothing reports that it was configured wrongly. + relayer := chain.SenderAccount.GetAddress().String() + + kv2.SetConfig(ctx, clientID, clientv2types.NewConfig(relayer)) + + cfg := kv2.GetConfig(ctx, clientID) + require.Equal(t, []string{relayer}, cfg.AllowedRelayers) + require.True(t, cfg.IsAllowedRelayer(chain.SenderAccount.GetAddress()), + "the configured signer must pass the allowlist check") + + // A different account must not, which is what makes the setting meaningful. + require.False(t, cfg.IsAllowedRelayer(chain.SenderAccounts[1].SenderAccount.GetAddress()), + "an account outside the allowlist must be rejected") + }) + + t.Run("5.6 the client-keyed rate limit cannot be set before the voucher exists", func(t *testing.T) { + // T8.4 says v2 quotas are keyed by client id and fail silently when missing, + // so the instinct is to add them at deploy time, before any value moves. + // The module refuses: AddRateLimit computes a percentage of the denom's + // supply and rejects a zero channel value. + // + // So the quota cannot exist before the first voucher does — see the ordering + // note this test pins below. + voucher := "ibc/" + clientID + + err := a.RateLimitKeeper.AddRateLimit(ctx, &ratelimittypes.MsgAddRateLimit{ + Authority: a.RateLimitKeeper.GetAuthority(), + Denom: voucher, + ChannelOrClientId: clientID, + MaxPercentSend: sdkmath.NewInt(10), + MaxPercentRecv: sdkmath.NewInt(10), + DurationHours: 24, + }) + require.ErrorIs(t, err, ratelimittypes.ErrZeroChannelValue, + "a quota on a denom with no supply must be refused — this is the ordering constraint, not a bug") + }) + + t.Run("5.6 the rate limit applies once the voucher has supply", func(t *testing.T) { + voucher := "ibc/" + clientID + + // Stand in for the first inbound transfer, which is what brings the voucher + // denom into existence. + coins := sdktypes.NewCoins(sdktypes.NewCoin(voucher, sdkmath.NewInt(1_000_000))) + require.NoError(t, a.BankKeeper.MintCoins(ctx, transfertypes.ModuleName, coins), + "minting a voucher to give the denom supply") + + err := a.RateLimitKeeper.AddRateLimit(ctx, &ratelimittypes.MsgAddRateLimit{ + Authority: a.RateLimitKeeper.GetAuthority(), + Denom: voucher, + ChannelOrClientId: clientID, + MaxPercentSend: sdkmath.NewInt(10), + MaxPercentRecv: sdkmath.NewInt(10), + DurationHours: 24, + }) + require.NoError(t, err, "adding a client-keyed rate limit once supply exists") + + limit, found := a.RateLimitKeeper.GetRateLimit(ctx, voucher, clientID) + require.True(t, found, "the rate limit must be keyed by client id, not by channel") + require.Equal(t, clientID, limit.Path.ChannelOrClientId) + }) +} diff --git a/x/qbftclient/prover/besu/client.go b/x/qbftclient/prover/besu/client.go new file mode 100644 index 00000000..131a0b1a --- /dev/null +++ b/x/qbftclient/prover/besu/client.go @@ -0,0 +1,146 @@ +// Package besu implements prover.ChainReader against a live Besu JSON-RPC +// endpoint. +// +// Everything here is I/O. The one piece of judgement is the self-check in +// HeaderByNumber, which is described there and is the reason this package exists +// rather than a bare ethclient call. +package besu + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient/gethclient" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// Client reads a Besu node over JSON-RPC. +type Client struct { + rpc *rpc.Client + geth *gethclient.Client +} + +// Dial connects to a Besu JSON-RPC endpoint. +func Dial(ctx context.Context, url string) (*Client, error) { + c, err := rpc.DialContext(ctx, url) + if err != nil { + return nil, fmt.Errorf("besu: dialling %s: %w", url, err) + } + return New(c), nil +} + +// New wraps an existing RPC client. +func New(c *rpc.Client) *Client { + return &Client{rpc: c, geth: gethclient.New(c)} +} + +// Close releases the underlying connection. +func (c *Client) Close() { c.rpc.Close() } + +// HeaderByNumber fetches a block header and proves our encoding of it is correct +// before handing it on. +// +// JSON-RPC returns a header as fields, not as RLP, so it has to be reassembled — +// and the light client's whole security rests on the reassembly being byte-exact, +// because every hash is taken over those bytes. A field this node populates and +// go-ethereum's struct ignores, or an encoding difference in extraData, produces a +// header that looks fine and fails every signature check. +// +// So the reassembled header is hashed with our own QBFT block-hash implementation +// and compared against the hash the node reported. They agree only if our RLP +// encoding, our extraData codec and Besu's all match. A mismatch is a bug here, not +// a consensus event, and it stops at this boundary rather than surfacing as an +// unexplainable verification failure on-chain. +func (c *Client) HeaderByNumber(ctx context.Context, height uint64) (*ethtypes.Header, error) { + var raw json.RawMessage + if err := c.rpc.CallContext(ctx, &raw, "eth_getBlockByNumber", hexutil.Uint64(height), false); err != nil { + return nil, fmt.Errorf("besu: eth_getBlockByNumber(%d): %w", height, err) + } + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("besu: no block at height %d", height) + } + + var header ethtypes.Header + if err := json.Unmarshal(raw, &header); err != nil { + return nil, fmt.Errorf("besu: decoding header %d: %w", height, err) + } + + var reported struct { + Hash common.Hash `json:"hash"` + } + if err := json.Unmarshal(raw, &reported); err != nil { + return nil, fmt.Errorf("besu: reading reported hash for %d: %w", height, err) + } + + computed, err := types.BlockHash(&header) + if err != nil { + return nil, fmt.Errorf("besu: hashing header %d: %w", height, err) + } + if computed != reported.Hash { + return nil, fmt.Errorf( + "besu: re-encoded header %d hashes to %s but the node reports %s — our header encoding does not match this chain's", + height, computed, reported.Hash) + } + + return &header, nil +} + +// ProofAt returns eth_getProof material for slots under account. +func (c *Client) ProofAt( + ctx context.Context, + account common.Address, + slots []common.Hash, + height uint64, +) (*prover.AccountProof, error) { + keys := make([]string, len(slots)) + for i, s := range slots { + keys[i] = s.Hex() + } + + res, err := c.geth.GetProof(ctx, account, keys, new(big.Int).SetUint64(height)) + if err != nil { + return nil, fmt.Errorf("besu: eth_getProof(%s, %d): %w", account, height, err) + } + if len(res.StorageProof) != len(slots) { + return nil, fmt.Errorf("besu: asked for %d slots, got %d proofs", len(slots), len(res.StorageProof)) + } + + out := &prover.AccountProof{} + if out.AccountProof, err = decodeNodes(res.AccountProof); err != nil { + return nil, fmt.Errorf("besu: account proof: %w", err) + } + for i, sp := range res.StorageProof { + nodes, err := decodeNodes(sp.Proof) + if err != nil { + return nil, fmt.Errorf("besu: storage proof %d: %w", i, err) + } + out.StorageProofs = append(out.StorageProofs, nodes) + } + return out, nil +} + +// decodeNodes turns the hex-encoded trie nodes of an eth_getProof response into +// bytes. An empty list is valid: a slot under an empty storage trie has no nodes to +// walk, which is the state a freshly deployed contract is in. +func decodeNodes(hexNodes []string) ([][]byte, error) { + out := make([][]byte, 0, len(hexNodes)) + for i, h := range hexNodes { + bz, err := hexutil.Decode(h) + if err != nil { + return nil, fmt.Errorf("node %d: %w", i, err) + } + out = append(out, bz) + } + return out, nil +} + +// Compile-time proof that this satisfies what the prover needs. +var _ prover.ChainReader = (*Client)(nil) diff --git a/x/qbftclient/prover/besu/client_test.go b/x/qbftclient/prover/besu/client_test.go new file mode 100644 index 00000000..bc78aeda --- /dev/null +++ b/x/qbftclient/prover/besu/client_test.go @@ -0,0 +1,132 @@ +package besu_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// jsonRPCServer answers eth_getBlockByNumber with a fixed block. +func jsonRPCServer(t *testing.T, block json.RawMessage) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + return + } + if req.Method != "eth_getBlockByNumber" { + t.Errorf("unexpected method %q", req.Method) + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte( + `{"jsonrpc":"2.0","id":` + string(req.ID) + `,"result":` + string(block) + `}`, + )); err != nil { + t.Errorf("write response: %v", err) + } + })) +} + +// blockJSON renders a header the way eth_getBlockByNumber does, with the hash the +// caller specifies rather than the correct one, so tests can force a mismatch. +func blockJSON(t *testing.T, h *ethtypes.Header, hash common.Hash) json.RawMessage { + t.Helper() + + bz, err := json.Marshal(h) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(bz, &fields); err != nil { + t.Fatalf("unmarshal header fields: %v", err) + } + if fields["hash"], err = json.Marshal(hash); err != nil { + t.Fatalf("marshal hash: %v", err) + } + + out, err := json.Marshal(fields) + if err != nil { + t.Fatalf("marshal block: %v", err) + } + return out +} + +func dial(t *testing.T, url string) *besu.Client { + t.Helper() + c, err := rpc.DialContext(context.Background(), url) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(c.Close) + return besu.New(c) +} + +// The header a node serves as JSON has to be reassembled, and every hash the light +// client checks is taken over that reassembly. This is the test that our +// reassembly agrees with the chain's. +func TestHeaderByNumber_AcceptsAMatchingHash(t *testing.T) { + keys := qbfttestutil.Keys(t, 4) + header := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{Height: 101, Time: 1_101, Seals: 3}) + + want, err := types.BlockHash(header) + if err != nil { + t.Fatalf("BlockHash: %v", err) + } + + srv := jsonRPCServer(t, blockJSON(t, header, want)) + defer srv.Close() + + got, err := dial(t, srv.URL).HeaderByNumber(context.Background(), 101) + if err != nil { + t.Fatalf("HeaderByNumber: %v", err) + } + if got.Number.Uint64() != 101 { + t.Errorf("height = %d, want 101", got.Number) + } + if string(got.Extra) != string(header.Extra) { + t.Error("extraData did not survive the JSON round trip") + } +} + +// The failure this guards against is silent: a header that decodes cleanly but +// re-encodes to different bytes verifies against nothing, and the error would +// otherwise surface as an unexplainable signature failure on-chain. +func TestHeaderByNumber_RejectsAHashMismatch(t *testing.T) { + keys := qbfttestutil.Keys(t, 4) + header := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{Height: 101, Time: 1_101, Seals: 3}) + + srv := jsonRPCServer(t, blockJSON(t, header, common.HexToHash("0xdeadbeef"))) + defer srv.Close() + + _, err := dial(t, srv.URL).HeaderByNumber(context.Background(), 101) + if err == nil { + t.Fatal("a header whose hash does not match must be refused") + } + if !strings.Contains(err.Error(), "does not match this chain") { + t.Errorf("the error should name the cause, got %v", err) + } +} + +func TestHeaderByNumber_MissingBlock(t *testing.T) { + srv := jsonRPCServer(t, json.RawMessage("null")) + defer srv.Close() + + _, err := dial(t, srv.URL).HeaderByNumber(context.Background(), 999) + if err == nil || !strings.Contains(err.Error(), "no block at height") { + t.Errorf("a missing block must say so, got %v", err) + } +} diff --git a/x/qbftclient/prover/besu/live_storage_test.go b/x/qbftclient/prover/besu/live_storage_test.go new file mode 100644 index 00000000..05f3966d --- /dev/null +++ b/x/qbftclient/prover/besu/live_storage_test.go @@ -0,0 +1,185 @@ +package besu_test + +import ( + "context" + "crypto/ecdsa" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// storageWriterCode deploys a contract whose whole behaviour is +// `sstore(calldata[0:32], calldata[32:64])`. +// +// It stands in for ICS20Transfer for one purpose only: writing a chosen 32-byte +// value at a chosen slot, so a real storage proof can be taken over the slot our +// own derivation computes. Deploying the real contracts needs Foundry and is LNET's +// job anyway; what has to be checked here is that our slot arithmetic and proof +// verification agree with a real EVM and a real trie. +// +// init: 6008600c60003960086000f3 copy 8 bytes of runtime from offset 12, return +// runtime: 6020356000355500 value=calldata[32], key=calldata[0], SSTORE, STOP +const storageWriterCode = "0x6008600c60003960086000f36020356000355500" + +// besuChainID matches the genesis this test expects; zeroBaseFee lets gas price be 0. +var besuChainID = big.NewInt(1338) + +// liveRPC returns a raw RPC client, or skips. QBFT_BESU_KEY must hold the hex +// private key of a genesis-funded account — a devnet key, never a real one. +func liveRPC(t *testing.T) (*rpc.Client, string) { + t.Helper() + + url := os.Getenv("QBFT_BESU_RPC") + key := os.Getenv("QBFT_BESU_KEY") + if url == "" || key == "" { + t.Skip("QBFT_BESU_RPC and QBFT_BESU_KEY not set; skipping live storage checks") + } + + c, err := rpc.DialContext(context.Background(), url) + if err != nil { + t.Fatalf("dial %s: %v", url, err) + } + t.Cleanup(c.Close) + return c, key +} + +// TestLive_PacketCommitmentSlotProvesAgainstRealStorage is the last link: a value +// written at the slot PacketCommitmentSlot computes, proved out of a real Besu trie +// and verified by the same code the light client runs. +// +// It exercises the ERC-7201 namespace arithmetic, Solidity's mapping-slot rule, the +// storage proof, and the leading-zero trimming — the commitment below starts with a +// zero byte on purpose, because that is the case a naive comparison gets wrong. +func TestLive_PacketCommitmentSlotProvesAgainstRealStorage(t *testing.T) { + rpcClient, keyHex := liveRPC(t) + ctx := context.Background() + client := besu.New(rpcClient) + + key, err := crypto.HexToECDSA(keyHex) + if err != nil { + t.Fatalf("parse key: %v", err) + } + from := crypto.PubkeyToAddress(key.PublicKey) + + contract := deploy(t, ctx, rpcClient, key, from) + t.Logf("storage-writer contract at %s", contract) + + const clientID = "qbft-0" + const sequence = 1 + slot := types.PacketCommitmentSlot(clientID, sequence) + commitment := common.HexToHash("0x00cafebabe112233445566778899aabbccddeeff00112233445566778899aabb") + + // calldata is slot || value, which the runtime SSTOREs directly. + writeAt := send(t, ctx, rpcClient, key, from, &contract, append(slot.Bytes(), commitment.Bytes()...)) + + header, err := client.HeaderByNumber(ctx, writeAt) + if err != nil { + t.Fatalf("header at %d: %v", writeAt, err) + } + + proof, err := client.ProofAt(ctx, contract, []common.Hash{slot}, writeAt) + if err != nil { + t.Fatalf("ProofAt: %v", err) + } + + if err := types.VerifyCommitment( + header.Root, contract, slot, commitment, + proof.AccountProof, proof.StorageProofs[0], + ); err != nil { + t.Fatalf("a commitment written at our derived slot must verify out of real storage: %v", err) + } + + // And the slot next door must prove absent, which is the timeout path. + absent := types.PacketReceiptSlot(clientID, sequence) + absentProof, err := client.ProofAt(ctx, contract, []common.Hash{absent}, writeAt) + if err != nil { + t.Fatalf("ProofAt (absent): %v", err) + } + if err := types.VerifyStorageAbsent( + header.Root, contract, absent, + absentProof.AccountProof, absentProof.StorageProofs[0], + ); err != nil { + t.Errorf("an unwritten receipt slot must prove absent: %v", err) + } +} + +func deploy(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address) common.Address { + t.Helper() + + code, err := hexutil.Decode(storageWriterCode) + if err != nil { + t.Fatalf("decode init code: %v", err) + } + send(t, ctx, c, key, from, nil, code) + + // The contract address is deterministic from the deployer and the nonce used. + nonce := nonceOf(t, ctx, c, from) + return crypto.CreateAddress(from, nonce-1) +} + +// send signs and submits a transaction, waits for it to be mined, and returns the +// block it landed in. +func send(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address, to *common.Address, data []byte) uint64 { + t.Helper() + + nonce := nonceOf(t, ctx, c, from) + + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: nonce, + To: to, + Gas: 1_000_000, + GasPrice: big.NewInt(0), // zeroBaseFee + Data: data, + }) + + signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(besuChainID), key) + if err != nil { + t.Fatalf("sign tx: %v", err) + } + raw, err := signed.MarshalBinary() + if err != nil { + t.Fatalf("encode tx: %v", err) + } + + var hash common.Hash + if err := c.CallContext(ctx, &hash, "eth_sendRawTransaction", hexutil.Encode(raw)); err != nil { + t.Fatalf("eth_sendRawTransaction: %v", err) + } + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + var receipt struct { + BlockNumber hexutil.Uint64 `json:"blockNumber"` + Status hexutil.Uint64 `json:"status"` + } + err := c.CallContext(ctx, &receipt, "eth_getTransactionReceipt", hash) + if err == nil && receipt.BlockNumber != 0 { + if receipt.Status != 1 { + t.Fatalf("transaction %s reverted", hash) + } + return uint64(receipt.BlockNumber) + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("transaction %s was not mined in time", hash) + return 0 +} + +func nonceOf(t *testing.T, ctx context.Context, c *rpc.Client, addr common.Address) uint64 { + t.Helper() + var n hexutil.Uint64 + if err := c.CallContext(ctx, &n, "eth_getTransactionCount", addr, "pending"); err != nil { + t.Fatalf("eth_getTransactionCount: %v", err) + } + return uint64(n) +} diff --git a/x/qbftclient/prover/besu/live_test.go b/x/qbftclient/prover/besu/live_test.go new file mode 100644 index 00000000..3d8b3e3c --- /dev/null +++ b/x/qbftclient/prover/besu/live_test.go @@ -0,0 +1,158 @@ +package besu_test + +import ( + "context" + "os" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover/besu" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// The live tests below run against a real Besu/QBFT node, which is the only thing +// that can prove our encodings match the chain's. Synthetic fixtures verify that we +// are self-consistent; nothing but a real node verifies that we agree with Besu. +// +// Bring one up with the cbweb3 genesis shape and run: +// +// QBFT_BESU_RPC=http://127.0.0.1:8645 go test ./x/qbftclient/prover/besu/ +// +// Without the variable they skip, so this stays runnable in CI without Besu. +func liveClient(t *testing.T) *besu.Client { + t.Helper() + + url := os.Getenv("QBFT_BESU_RPC") + if url == "" { + t.Skip("QBFT_BESU_RPC not set; skipping live Besu checks") + } + + c, err := besu.Dial(context.Background(), url) + if err != nil { + t.Fatalf("dial %s: %v", url, err) + } + t.Cleanup(c.Close) + return c +} + +// If our extraData codec, our RLP encoding or our round/seal handling diverged from +// Besu's by a single byte, the hash would differ and HeaderByNumber would refuse the +// header. Passing this against a real chain is what validates x/qbftclient/types. +func TestLive_HeaderEncodingMatchesTheChain(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + + for _, height := range []uint64{1, 2, 3} { + h, err := c.HeaderByNumber(ctx, height) + if err != nil { + t.Fatalf("height %d: %v", height, err) + } + if h.Number.Uint64() != height { + t.Errorf("height %d: got %d", height, h.Number) + } + } +} + +// A real header's committed seals must recover to the real validator set, and meet +// the quorum our RequiredQuorum computes. This exercises the commit-seal digest — +// the round-preserving, seal-stripping encoding — against genuine signatures. +func TestLive_CommitSealsVerify(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + + // The set trusted for height N is the one carried by N-1, exactly as the light + // client advances it. + previous, err := c.HeaderByNumber(ctx, 1) + if err != nil { + t.Fatalf("height 1: %v", err) + } + trusted, err := types.HeaderValidators(previous) + if err != nil { + t.Fatalf("validators at height 1: %v", err) + } + + header, err := c.HeaderByNumber(ctx, 2) + if err != nil { + t.Fatalf("height 2: %v", err) + } + + if err := types.VerifyCommitSeals(header, trusted); err != nil { + t.Fatalf("real committed seals must verify against the real validator set: %v", err) + } + + committers, err := types.RecoverCommitters(header) + if err != nil { + t.Fatalf("RecoverCommitters: %v", err) + } + if len(committers) < types.RequiredQuorum(len(trusted)) { + t.Errorf("recovered %d seals, quorum is %d of %d validators", + len(committers), types.RequiredQuorum(len(trusted)), len(trusted)) + } +} + +// eth_getProof output must satisfy our Merkle-Patricia verifier against the state +// root in the header we independently re-encoded. This closes the loop between the +// two halves — header verification and proof verification — on real data. +// ⚠️ Note the height: proofs are taken at a *recent* block on purpose. +// +// Besu prunes historical world state, so eth_getProof against an old height fails +// with "World state unavailable" — the header is still served, but the state behind +// it is gone. That is an operational constraint on the corridor, not a quirk of this +// test: a relayer that falls far enough behind cannot produce a proof at all, and +// the spoke's nodes must retain state for at least as long as the relayer may lag. +func TestLive_AccountProofVerifies(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + + height := latestHeight(t, ctx) - 1 + header, err := c.HeaderByNumber(ctx, height) + if err != nil { + t.Fatalf("header: %v", err) + } + + // A genesis-funded dev account: it exists, so its proof is a membership proof. + account := common.HexToAddress("0xfe3b557e8fb62b89f4916b721be55ceb828dbd73") + + proof, err := c.ProofAt(ctx, account, []common.Hash{common.HexToHash("0x0")}, height) + if err != nil { + t.Fatalf("ProofAt: %v", err) + } + + acc, err := types.VerifyAccount(header.Root, account, proof.AccountProof) + if err != nil { + t.Fatalf("a real account proof must verify against the state root we re-encoded: %v", err) + } + if acc.Balance == nil || acc.Balance.IsZero() { + t.Error("the genesis-funded account should carry a balance") + } + + // The account has no contract storage, so slot 0 must prove absent — the same + // path a timeout takes before any packet has been received. + if err := types.VerifyStorageAbsent( + header.Root, account, common.HexToHash("0x0"), + proof.AccountProof, proof.StorageProofs[0], + ); err != nil { + t.Errorf("an unset slot on a real account must prove absent: %v", err) + } +} + +// latestHeight reads the chain head, so proofs can be taken at a height whose world +// state Besu still retains. +func latestHeight(t *testing.T, ctx context.Context) uint64 { + t.Helper() + + c, err := rpc.DialContext(ctx, os.Getenv("QBFT_BESU_RPC")) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + var head hexutil.Uint64 + if err := c.CallContext(ctx, &head, "eth_blockNumber"); err != nil { + t.Fatalf("eth_blockNumber: %v", err) + } + return uint64(head) +} diff --git a/x/qbftclient/prover/cosmos/prover.go b/x/qbftclient/prover/cosmos/prover.go new file mode 100644 index 00000000..e36d14a9 --- /dev/null +++ b/x/qbftclient/prover/cosmos/prover.go @@ -0,0 +1,195 @@ +// Package cosmos proves cbdc-node's own IBC state — the outbound leg of the +// corridor, where the counterparty has to be convinced of something this chain +// committed. +// +// It is the mirror of x/qbftclient/prover: that package reads a Besu chain for the +// inbound leg, this one reads cbdc-node for the return leg. Both produce proofs and +// neither signs anything. +// +// ⚠️ Which of these outputs the counterparty actually consumes depends on decision +// D4, which is settled only for the pilot. An SP1 light client of cbdc-node +// verifies the ICS-23 proofs produced here directly. The pilot's attestation client +// does not: it verifies signatures over (height, path, commitment), so for that +// path these queries are the *source of truth attestors sign over* rather than the +// proof itself. Either way the query is the same, which is why it is built now. +package cosmos + +import ( + "bytes" + "context" + "fmt" + + cmtbytes "github.com/cometbft/cometbft/libs/bytes" + rpcclient "github.com/cometbft/cometbft/rpc/client" + coretypes "github.com/cometbft/cometbft/rpc/core/types" + + "github.com/cosmos/cosmos-sdk/codec" + + 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" + commitmenttypes "github.com/cosmos/ibc-go/v10/modules/core/23-commitment/types" + hostv2 "github.com/cosmos/ibc-go/v10/modules/core/24-host/v2" +) + +// ibcStorePath is the ABCI store query path for the IBC module's KVStore. +const ibcStorePath = "/store/ibc/key" + +// RPC is the slice of a CometBFT endpoint this package needs. It is an interface so +// the prover can be tested without a running node. +type RPC interface { + Status(ctx context.Context) (*coretypes.ResultStatus, error) + ABCIQueryWithOptions( + ctx context.Context, + path string, + data cmtbytes.HexBytes, + opts rpcclient.ABCIQueryOptions, + ) (*coretypes.ResultABCIQuery, error) +} + +// Proof is a proven value from cbdc-node's IBC store. +type Proof struct { + // Value is the stored bytes — a commitment hash, or empty for an absence proof. + Value []byte + // Proof is the marshalled ICS-23 merkle proof. + Proof []byte + // Height is the height the counterparty must verify the proof at. It is one + // above the queried height; see SettledHeight. + Height clienttypes.Height +} + +// Prover reads proofs out of a cbdc-node instance. +type Prover struct { + rpc RPC + cdc codec.BinaryCodec + chainID string +} + +// New returns a Prover reading rpc. chainID is used to derive the revision number +// the counterparty expects in a proof height. +func New(rpc RPC, cdc codec.BinaryCodec, chainID string) *Prover { + return &Prover{rpc: rpc, cdc: cdc, chainID: chainID} +} + +// SettledHeight returns the highest height safe to query proofs at. +// +// An ABCI proof returned for height h is verified against the app hash committed in +// block h+1, so the latest block cannot be proved against — its app hash has not +// been committed anywhere yet. Querying at latest-1 is what makes the reported +// proof height, latest, a height the counterparty can actually check. +func (p *Prover) SettledHeight(ctx context.Context) (int64, error) { + status, err := p.rpc.Status(ctx) + if err != nil { + return 0, fmt.Errorf("cosmos: querying status: %w", err) + } + h := status.SyncInfo.LatestBlockHeight - 1 + if h < 1 { + return 0, fmt.Errorf("cosmos: chain has not produced enough blocks to prove against (latest %d)", + status.SyncInfo.LatestBlockHeight) + } + return h, nil +} + +// PacketCommitment proves the commitment cbdc-node stored when it sent a packet. +// +// The packet is required, not optional: proving the stored commitment while the +// caller submits a different packet is rejected by the counterparty *after* the +// operator has signed and paid for the round trip. The commitment is the +// authoritative fingerprint, so the mismatch is caught here instead. +func (p *Prover) PacketCommitment( + ctx context.Context, + packet channeltypesv2.Packet, + height int64, +) (*Proof, error) { + key := hostv2.PacketCommitmentKey(packet.SourceClient, packet.Sequence) + + proof, err := p.query(ctx, key, height) + if err != nil { + return nil, err + } + if len(proof.Value) == 0 { + return nil, fmt.Errorf("cosmos: no packet commitment for %s sequence %d — wrong client or sequence?", + packet.SourceClient, packet.Sequence) + } + if want := channeltypesv2.CommitPacket(packet); !bytes.Equal(proof.Value, want) { + return nil, fmt.Errorf( + "cosmos: packet does not match the commitment stored at %s sequence %d: stored %x, packet commits to %x — stale or edited packet?", + packet.SourceClient, packet.Sequence, proof.Value, want) + } + return proof, nil +} + +// PacketAcknowledgement proves the acknowledgement cbdc-node wrote for a received +// packet, which is what lets the counterparty settle its escrow. +func (p *Prover) PacketAcknowledgement( + ctx context.Context, + destClient string, + sequence uint64, + height int64, +) (*Proof, error) { + proof, err := p.query(ctx, hostv2.PacketAcknowledgementKey(destClient, sequence), height) + if err != nil { + return nil, err + } + if len(proof.Value) == 0 { + return nil, fmt.Errorf("cosmos: no acknowledgement for %s sequence %d", destClient, sequence) + } + return proof, nil +} + +// PacketReceiptAbsence proves cbdc-node never received a packet, which is what a +// timeout on the counterparty asserts. +// +// An empty value is the expected result here rather than a failure — that is the +// whole content of the proof. +func (p *Prover) PacketReceiptAbsence( + ctx context.Context, + destClient string, + sequence uint64, + height int64, +) (*Proof, error) { + proof, err := p.query(ctx, hostv2.PacketReceiptKey(destClient, sequence), height) + if err != nil { + return nil, err + } + if len(proof.Value) != 0 { + return nil, fmt.Errorf("cosmos: %s sequence %d was received — a timeout would be false", + destClient, sequence) + } + return proof, nil +} + +func (p *Prover) query(ctx context.Context, key []byte, height int64) (*Proof, error) { + res, err := p.rpc.ABCIQueryWithOptions(ctx, ibcStorePath, key, rpcclient.ABCIQueryOptions{ + Height: height, + Prove: true, + }) + if err != nil { + return nil, fmt.Errorf("cosmos: abci query: %w", err) + } + // An app-level failure — pruned height, height above latest, unknown store — + // comes back with a nil error and a non-zero code, so without this check it + // would be misreported as a missing value. + if res.Response.Code != 0 { + return nil, fmt.Errorf("cosmos: abci query failed at height %d: code %d: %s", + height, res.Response.Code, res.Response.Log) + } + + merkleProof, err := commitmenttypes.ConvertProofs(res.Response.ProofOps) + if err != nil { + return nil, fmt.Errorf("cosmos: converting proof ops (is prove=true supported?): %w", err) + } + bz, err := p.cdc.Marshal(&merkleProof) + if err != nil { + return nil, fmt.Errorf("cosmos: marshalling merkle proof: %w", err) + } + + return &Proof{ + Value: res.Response.Value, + Proof: bz, + Height: clienttypes.NewHeight( + clienttypes.ParseChainID(p.chainID), + //nolint:gosec // an ABCI query response height is never negative + uint64(res.Response.Height)+1, + ), + }, nil +} diff --git a/x/qbftclient/prover/cosmos/prover_test.go b/x/qbftclient/prover/cosmos/prover_test.go new file mode 100644 index 00000000..c958901d --- /dev/null +++ b/x/qbftclient/prover/cosmos/prover_test.go @@ -0,0 +1,217 @@ +package cosmos_test + +import ( + "context" + "strings" + "testing" + + abci "github.com/cometbft/cometbft/abci/types" + cmtbytes "github.com/cometbft/cometbft/libs/bytes" + "github.com/cometbft/cometbft/p2p" + cryptoproto "github.com/cometbft/cometbft/proto/tendermint/crypto" + rpcclient "github.com/cometbft/cometbft/rpc/client" + coretypes "github.com/cometbft/cometbft/rpc/core/types" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + ics23 "github.com/cosmos/ics23/go" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover/cosmos" +) + +const chainID = "cbdc_1449999-1" + +// fakeRPC is a CometBFT endpoint whose answers the test dictates. +type fakeRPC struct { + latest int64 + resp abci.ResponseQuery + err error +} + +func (f *fakeRPC) Status(context.Context) (*coretypes.ResultStatus, error) { + return &coretypes.ResultStatus{ + NodeInfo: p2p.DefaultNodeInfo{Network: chainID}, + SyncInfo: coretypes.SyncInfo{LatestBlockHeight: f.latest}, + }, nil +} + +func (f *fakeRPC) ABCIQueryWithOptions( + _ context.Context, _ string, _ cmtbytes.HexBytes, _ rpcclient.ABCIQueryOptions, +) (*coretypes.ResultABCIQuery, error) { + if f.err != nil { + return nil, f.err + } + return &coretypes.ResultABCIQuery{Response: f.resp}, nil +} + +// validProofOps returns proof ops that ConvertProofs accepts. The contents are not +// checked by anything in this package — the counterparty's light client is what +// verifies them — so a well-formed existence proof is enough. +func validProofOps(t *testing.T) *cryptoproto.ProofOps { + t.Helper() + p := &ics23.CommitmentProof{ + Proof: &ics23.CommitmentProof_Exist{ + Exist: &ics23.ExistenceProof{Key: []byte("key"), Value: []byte("value")}, + }, + } + bz, err := p.Marshal() + if err != nil { + t.Fatalf("marshal ics23 proof: %v", err) + } + return &cryptoproto.ProofOps{Ops: []cryptoproto.ProofOp{{Type: "ics23:iavl", Key: []byte("key"), Data: bz}}} +} + +func newCodec() *codec.ProtoCodec { + return codec.NewProtoCodec(codectypes.NewInterfaceRegistry()) +} + +func packetFor(t *testing.T, client string, sequence uint64) channeltypesv2.Packet { + t.Helper() + return channeltypesv2.Packet{ + Sequence: sequence, + SourceClient: client, + DestinationClient: "qbft-0", + TimeoutTimestamp: 1_000, + Payloads: []channeltypesv2.Payload{{ + SourcePort: "transfer", + DestinationPort: "transfer", + Version: "ics20-1", + Encoding: "application/x-solidity-abi", + Value: []byte("payload"), + }}, + } +} + +// The latest block cannot be proved against: its app hash is committed in the block +// after it, which does not exist yet. +func TestSettledHeight(t *testing.T) { + p := cosmos.New(&fakeRPC{latest: 100}, newCodec(), chainID) + + got, err := p.SettledHeight(context.Background()) + if err != nil { + t.Fatalf("SettledHeight: %v", err) + } + if got != 99 { + t.Errorf("settled height = %d, want 99", got) + } +} + +func TestSettledHeight_ChainTooShort(t *testing.T) { + p := cosmos.New(&fakeRPC{latest: 1}, newCodec(), chainID) + + if _, err := p.SettledHeight(context.Background()); err == nil { + t.Error("a chain with no settled height must report an error, not height 0") + } +} + +func TestPacketCommitment(t *testing.T) { + packet := packetFor(t, "07-tendermint-0", 7) + + rpc := &fakeRPC{resp: abci.ResponseQuery{ + Value: channeltypesv2.CommitPacket(packet), + Height: 99, + ProofOps: validProofOps(t), + }} + + proven, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 99) + if err != nil { + t.Fatalf("PacketCommitment: %v", err) + } + if len(proven.Proof) == 0 { + t.Error("proof bytes must be populated") + } + // The proof height reported to the counterparty is one above the queried + // height, because that is the block whose app hash commits to this state. + if proven.Height.RevisionHeight != 100 { + t.Errorf("proof height = %d, want 100", proven.Height.RevisionHeight) + } +} + +// The check that saves an operator a signed, paid-for round trip: proving the +// stored commitment while submitting a different packet is rejected on the +// counterparty, long after this tool has exited. +func TestPacketCommitment_PacketDoesNotMatchCommitment(t *testing.T) { + stored := packetFor(t, "07-tendermint-0", 7) + submitted := packetFor(t, "07-tendermint-0", 7) + submitted.TimeoutTimestamp = 2_000 // same client and sequence, different packet + + rpc := &fakeRPC{resp: abci.ResponseQuery{ + Value: channeltypesv2.CommitPacket(stored), + Height: 99, + ProofOps: validProofOps(t), + }} + + _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), submitted, 99) + if err == nil || !strings.Contains(err.Error(), "stale or edited packet") { + t.Errorf("a packet disagreeing with the stored commitment must be rejected, got %v", err) + } +} + +func TestPacketCommitment_Missing(t *testing.T) { + packet := packetFor(t, "07-tendermint-0", 7) + rpc := &fakeRPC{resp: abci.ResponseQuery{Height: 99, ProofOps: validProofOps(t)}} + + _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 99) + if err == nil || !strings.Contains(err.Error(), "no packet commitment") { + t.Errorf("a missing commitment must say so, got %v", err) + } +} + +// An app-level failure returns a nil error with a non-zero code. Without an +// explicit check it reads as "no commitment", which sends the operator looking for +// the wrong problem. +func TestQuery_AppLevelFailureIsNotMistakenForAbsence(t *testing.T) { + packet := packetFor(t, "07-tendermint-0", 7) + rpc := &fakeRPC{resp: abci.ResponseQuery{Code: 18, Log: "height 42 is not available"}} + + _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 42) + if err == nil || !strings.Contains(err.Error(), "code 18") { + t.Errorf("an app-level query failure must surface its code, got %v", err) + } +} + +func TestQuery_MissingProofOps(t *testing.T) { + packet := packetFor(t, "07-tendermint-0", 7) + rpc := &fakeRPC{resp: abci.ResponseQuery{Value: channeltypesv2.CommitPacket(packet), Height: 99}} + + _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 99) + if err == nil || !strings.Contains(err.Error(), "prove=true") { + t.Errorf("a response with no proof ops must point at the missing prove flag, got %v", err) + } +} + +// A timeout asserts that no receipt was written, so an empty value is the proof, +// not a failure. +func TestPacketReceiptAbsence(t *testing.T) { + rpc := &fakeRPC{resp: abci.ResponseQuery{Height: 99, ProofOps: validProofOps(t)}} + + proven, err := cosmos.New(rpc, newCodec(), chainID).PacketReceiptAbsence(context.Background(), "qbft-0", 7, 99) + if err != nil { + t.Fatalf("PacketReceiptAbsence: %v", err) + } + if len(proven.Value) != 0 { + t.Error("an absence proof must carry no value") + } +} + +// The mirror: claiming a timeout for a packet that was received would be a false +// statement, so it is refused here rather than on the counterparty. +func TestPacketReceiptAbsence_ButItWasReceived(t *testing.T) { + rpc := &fakeRPC{resp: abci.ResponseQuery{Value: []byte{0x01}, Height: 99, ProofOps: validProofOps(t)}} + + _, err := cosmos.New(rpc, newCodec(), chainID).PacketReceiptAbsence(context.Background(), "qbft-0", 7, 99) + if err == nil || !strings.Contains(err.Error(), "a timeout would be false") { + t.Errorf("a written receipt must block a timeout proof, got %v", err) + } +} + +func TestPacketAcknowledgement_Missing(t *testing.T) { + rpc := &fakeRPC{resp: abci.ResponseQuery{Height: 99, ProofOps: validProofOps(t)}} + + _, err := cosmos.New(rpc, newCodec(), chainID).PacketAcknowledgement(context.Background(), "qbft-0", 7, 99) + if err == nil || !strings.Contains(err.Error(), "no acknowledgement") { + t.Errorf("a missing acknowledgement must say so, got %v", err) + } +} diff --git a/x/qbftclient/prover/msgs/msgs.go b/x/qbftclient/prover/msgs/msgs.go new file mode 100644 index 00000000..ef95adc8 --- /dev/null +++ b/x/qbftclient/prover/msgs/msgs.go @@ -0,0 +1,129 @@ +// Package msgs assembles the unsigned Cosmos messages that carry a QBFT prover's +// output onto cbdc-node. +// +// It is separate from the prover itself so that the prover — which produces the +// headers and proofs the light client verifies — stays free of ibc-go, the same way +// x/qbftclient/types does. An ibc-go version bump then touches this package and +// nothing that decides whether a proof is valid. +// +// Nothing here signs. Following cmd/v2relay, messages are built unsigned and handed +// to the chain's own tooling to sign and broadcast, which keeps key custody out of +// the relaying path entirely (DEC-7). +package msgs + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + + 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/x/qbftclient/types" +) + +// ProofHeight wraps a Besu block number as the height a proof is anchored to. +// Revision is always zero — QBFT has no revision concept. +func ProofHeight(blockNumber uint64) clienttypes.Height { + return clienttypes.NewHeight(0, blockNumber) +} + +// UpdateClient builds the message that advances a QBFT client by one header. +func UpdateClient(clientID string, header *types.Header, signer string) (*clienttypes.MsgUpdateClient, error) { + if err := header.ValidateBasic(); err != nil { + return nil, err + } + msg, err := clienttypes.NewMsgUpdateClient(clientID, header, signer) + if err != nil { + return nil, fmt.Errorf("msgs: building MsgUpdateClient: %w", err) + } + return msg, nil +} + +// UpdateClientChain builds one MsgUpdateClient per header, preserving order. +// +// Order is not cosmetic: each header is verified against the validator set the +// previous one carried, so submitting them out of order fails, and dropping one in +// the middle fails every header after it. +func UpdateClientChain(clientID string, headers []*types.Header, signer string) ([]sdk.Msg, error) { + out := make([]sdk.Msg, 0, len(headers)) + for i, h := range headers { + msg, err := UpdateClient(clientID, h, signer) + if err != nil { + return nil, fmt.Errorf("msgs: header %d of %d: %w", i+1, len(headers), err) + } + out = append(out, msg) + } + return out, nil +} + +// RecvPacket builds the message that delivers a packet proved out of the +// counterparty's storage. +// +// proofHeight must be a height the client has already been updated to; the proof is +// verified against that consensus state's storage root, so a proof read at a height +// the client has not seen is rejected rather than queued. +func RecvPacket( + cdc codec.BinaryCodec, + packet channeltypesv2.Packet, + proof *types.StorageProof, + proofHeight uint64, + signer string, +) (*channeltypesv2.MsgRecvPacket, error) { + bz, err := marshalProof(cdc, proof) + if err != nil { + return nil, err + } + return channeltypesv2.NewMsgRecvPacket(packet, bz, ProofHeight(proofHeight), signer), nil +} + +// Acknowledgement builds the message that returns an acknowledgement proved out of +// the counterparty's storage. +func Acknowledgement( + cdc codec.BinaryCodec, + packet channeltypesv2.Packet, + ack channeltypesv2.Acknowledgement, + proof *types.StorageProof, + proofHeight uint64, + signer string, +) (*channeltypesv2.MsgAcknowledgement, error) { + bz, err := marshalProof(cdc, proof) + if err != nil { + return nil, err + } + return channeltypesv2.NewMsgAcknowledgement(packet, ack, bz, ProofHeight(proofHeight), signer), nil +} + +// Timeout builds the message that refunds a packet the counterparty never received. +// +// The proof here is one of *absence*: that no receipt was written for the packet. +// A freshly deployed IBC contract has an empty storage trie, so that proof can +// legitimately carry no storage nodes at all. +func Timeout( + cdc codec.BinaryCodec, + packet channeltypesv2.Packet, + proof *types.StorageProof, + proofHeight uint64, + signer string, +) (*channeltypesv2.MsgTimeout, error) { + bz, err := marshalProof(cdc, proof) + if err != nil { + return nil, err + } + return channeltypesv2.NewMsgTimeout(packet, bz, ProofHeight(proofHeight), signer), nil +} + +func marshalProof(cdc codec.BinaryCodec, proof *types.StorageProof) ([]byte, error) { + if proof == nil { + return nil, fmt.Errorf("msgs: nil storage proof") + } + if err := proof.Validate(); err != nil { + return nil, err + } + bz, err := cdc.Marshal(proof) + if err != nil { + return nil, fmt.Errorf("msgs: marshalling storage proof: %w", err) + } + return bz, nil +} diff --git a/x/qbftclient/prover/msgs/msgs_test.go b/x/qbftclient/prover/msgs/msgs_test.go new file mode 100644 index 00000000..f0541c06 --- /dev/null +++ b/x/qbftclient/prover/msgs/msgs_test.go @@ -0,0 +1,190 @@ +package msgs_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + + 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/x/qbftclient" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/msgs" + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +const signer = "cbdc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + +var contract = common.HexToAddress("0x00000000000000000000000000000000cafebabe") + +func newCodec() *codec.ProtoCodec { + registry := codectypes.NewInterfaceRegistry() + qbftclient.RegisterInterfaces(registry) + return codec.NewProtoCodec(registry) +} + +func headerMsg(t *testing.T, height uint64) *types.Header { + t.Helper() + keys := qbfttestutil.Keys(t, 4) + h := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{Height: height, Time: 1_000 + height, Seals: 3}) + return &types.Header{RlpHeader: qbfttestutil.RLP(t, h)} +} + +// The header must survive being packed into an Any and unpacked again — that is the +// path the chain actually takes, and a registration mistake shows up nowhere else. +func TestUpdateClient_RoundTripsThroughAny(t *testing.T) { + cdc := newCodec() + original := headerMsg(t, 101) + + msg, err := msgs.UpdateClient("qbft-0", original, signer) + if err != nil { + t.Fatalf("UpdateClient: %v", err) + } + if msg.ClientId != "qbft-0" || msg.Signer != signer { + t.Errorf("client id / signer not carried through: %q %q", msg.ClientId, msg.Signer) + } + + bz, err := cdc.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded clienttypes.MsgUpdateClient + if err := cdc.Unmarshal(bz, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + unpacked, err := clienttypes.UnpackClientMessage(decoded.ClientMessage) + if err != nil { + t.Fatalf("unpack client message: %v", err) + } + header, ok := unpacked.(*types.Header) + if !ok { + t.Fatalf("unpacked %T, want *types.Header", unpacked) + } + if string(header.RlpHeader) != string(original.RlpHeader) { + t.Error("header RLP changed across the round trip") + } +} + +func TestUpdateClient_RejectsMalformedHeader(t *testing.T) { + if _, err := msgs.UpdateClient("qbft-0", &types.Header{RlpHeader: []byte{0x01, 0x02}}, signer); err == nil { + t.Error("a header that cannot decode must be rejected before it is submitted") + } +} + +// Order is load-bearing: each header is verified against the set the previous one +// carried, so the messages must come out in the order the prover produced them. +func TestUpdateClientChain_PreservesOrder(t *testing.T) { + headers := []*types.Header{headerMsg(t, 101), headerMsg(t, 105), headerMsg(t, 110)} + + out, err := msgs.UpdateClientChain("qbft-0", headers, signer) + if err != nil { + t.Fatalf("UpdateClientChain: %v", err) + } + if len(out) != len(headers) { + t.Fatalf("got %d messages, want %d", len(out), len(headers)) + } + + for i, m := range out { + msg, ok := m.(*clienttypes.MsgUpdateClient) + if !ok { + t.Fatalf("message %d is %T", i, m) + } + unpacked, err := clienttypes.UnpackClientMessage(msg.ClientMessage) + if err != nil { + t.Fatalf("message %d: %v", i, err) + } + if string(unpacked.(*types.Header).RlpHeader) != string(headers[i].RlpHeader) { + t.Errorf("message %d carries the wrong header", i) + } + } +} + +// The proof bytes in a MsgRecvPacket must unmarshal back into a proof that the +// light client's verifier accepts. This is the seam between the prover and the +// chain, and nothing else exercises it. +func TestRecvPacket_ProofSurvivesAndVerifies(t *testing.T) { + cdc := newCodec() + + const clientID = "qbft-0" + const sequence = 7 + commitment := common.HexToHash("0x00ddeeff00112233445566778899aabbccddeeff00112233445566778899aabb") + slot := types.PacketCommitmentSlot(clientID, sequence) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{slot: commitment.Bytes()}) + + proof := &types.StorageProof{ + AccountProof: state.AccountProof, + StorageProof: state.StorageProof(t, slot), + } + + packet := channeltypesv2.Packet{ + Sequence: sequence, + SourceClient: clientID, + DestinationClient: "07-tendermint-0", + } + + msg, err := msgs.RecvPacket(cdc, packet, proof, 101, signer) + if err != nil { + t.Fatalf("RecvPacket: %v", err) + } + if msg.ProofHeight != clienttypes.NewHeight(0, 101) { + t.Errorf("proof height = %s, want 0-101", msg.ProofHeight) + } + + var decoded types.StorageProof + if err := cdc.Unmarshal(msg.ProofCommitment, &decoded); err != nil { + t.Fatalf("unmarshal proof: %v", err) + } + if err := types.VerifyCommitment( + state.Root, contract, slot, commitment, + decoded.AccountProof, decoded.StorageProof, + ); err != nil { + t.Errorf("the proof carried in the message must still verify: %v", err) + } +} + +// A timeout asserts an unwritten receipt, and a freshly deployed contract has an +// empty storage trie — so the absence proof carries no storage nodes and must +// still be accepted here. +func TestTimeout_AcceptsEmptyStorageProof(t *testing.T) { + cdc := newCodec() + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{}) + slot := types.PacketReceiptSlot("qbft-0", 1) + + proof := &types.StorageProof{ + AccountProof: state.AccountProof, + StorageProof: state.StorageProof(t, slot), + } + + msg, err := msgs.Timeout(cdc, channeltypesv2.Packet{Sequence: 1, SourceClient: "qbft-0"}, proof, 101, signer) + if err != nil { + t.Fatalf("Timeout: %v", err) + } + + var decoded types.StorageProof + if err := cdc.Unmarshal(msg.ProofUnreceived, &decoded); err != nil { + t.Fatalf("unmarshal proof: %v", err) + } + if err := types.VerifyStorageAbsent( + state.Root, contract, slot, decoded.AccountProof, decoded.StorageProof, + ); err != nil { + t.Errorf("the absence proof carried in the message must still verify: %v", err) + } +} + +func TestRecvPacket_RejectsProofWithoutAccountHalf(t *testing.T) { + cdc := newCodec() + + _, err := msgs.RecvPacket(cdc, channeltypesv2.Packet{Sequence: 1}, &types.StorageProof{}, 101, signer) + if err == nil { + t.Error("a proof with no account half must be rejected before submission") + } + + if _, err := msgs.RecvPacket(cdc, channeltypesv2.Packet{Sequence: 1}, nil, 101, signer); err == nil { + t.Error("a nil proof must be rejected") + } +} diff --git a/x/qbftclient/prover/prover.go b/x/qbftclient/prover/prover.go new file mode 100644 index 00000000..87d513f5 --- /dev/null +++ b/x/qbftclient/prover/prover.go @@ -0,0 +1,252 @@ +// Package prover builds the material a QBFT light client consumes: client-update +// headers and Merkle-Patricia storage proofs read from a Besu node. +// +// It is a library, not a service. There is no event loop, no state store, no +// signing and no key custody — it turns "what does the client need to believe X" +// into bytes, and whoever operates the corridor submits them. That boundary is +// deliberate (DEC-7): the relayer is operated elsewhere, but the thing that +// produces exactly what x/qbftclient verifies has to live beside it, because the +// two are two halves of one encoding. +package prover + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rlp" + + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// AccountProof is one eth_getProof response, decoded from hex. +type AccountProof struct { + // AccountProof is the trie path from the state root to the account. + AccountProof [][]byte + // StorageProofs is the trie path to each requested slot, in request order. + StorageProofs [][][]byte +} + +// ChainReader is the slice of a Besu JSON-RPC endpoint this package needs. +// +// It is an interface so the prover can be tested against synthetic chains; the +// production implementation is a thin wrapper over go-ethereum's client. +type ChainReader interface { + // HeaderByNumber returns the block header at height. + HeaderByNumber(ctx context.Context, height uint64) (*ethtypes.Header, error) + // ProofAt returns the account and storage proofs for slots under account, as + // of height. + ProofAt(ctx context.Context, account common.Address, slots []common.Hash, height uint64) (*AccountProof, error) +} + +// Prover reads a Besu chain and produces client messages and proofs for it. +type Prover struct { + chain ChainReader + // contract is the counterparty IBC contract whose storage holds commitments. + contract common.Address +} + +// New returns a Prover reading chain, proving storage under contract. +func New(chain ChainReader, contract common.Address) *Prover { + return &Prover{chain: chain, contract: contract} +} + +// HeaderMessage builds the client message that advances the light client to height. +func (p *Prover) HeaderMessage(ctx context.Context, height uint64) (*types.Header, error) { + h, err := p.chain.HeaderByNumber(ctx, height) + if err != nil { + return nil, fmt.Errorf("prover: fetching header %d: %w", height, err) + } + return headerMessage(h) +} + +func headerMessage(h *ethtypes.Header) (*types.Header, error) { + bz, err := rlp.EncodeToBytes(h) + if err != nil { + return nil, fmt.Errorf("prover: encoding header: %w", err) + } + msg := &types.Header{RlpHeader: bz} + + // Fail here rather than on-chain: a header the client would reject outright is + // a bug in this package or a misconfigured endpoint, not a consensus event. + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + return msg, nil +} + +// UpdateChain returns the headers needed to advance a client from a state trusting +// `trusted` at height `from` up to height `to`. +// +// Usually that is one header. It is more when the validator set changed in +// between: the client verifies each header against the set carried by the last one +// it accepted, so a set change is a link that cannot be skipped over. Besu applies +// a set change at whatever block the deciding vote lands in — epoch blocks only +// discard outstanding votes, they are not the point where changes take effect +// (Besu: VoteTallyUpdater.updateForBlock) — so change points are not predictable +// from the epoch length and have to be found. +// +// Between two heights whose validator sets are identical, this assumes no change +// occurred. A set that changes and changes back within one range would be missed; +// the header at `to` would then fail verification on-chain rather than being +// accepted wrongly. Keeping update intervals short — which the corridor needs +// anyway to stay inside the trusting period — keeps ranges small enough that this +// does not arise in practice. +func (p *Prover) UpdateChain(ctx context.Context, trusted []common.Address, from, to uint64) ([]*types.Header, error) { + if to <= from { + return nil, fmt.Errorf("prover: target height %d is not above trusted height %d", to, from) + } + + target, err := p.chain.HeaderByNumber(ctx, to) + if err != nil { + return nil, fmt.Errorf("prover: fetching header %d: %w", to, err) + } + + targetSet, err := types.HeaderValidators(target) + if err != nil { + return nil, err + } + + // The common case: nothing changed, so the target verifies directly. + if sameSet(trusted, targetSet) { + msg, err := headerMessage(target) + if err != nil { + return nil, err + } + return []*types.Header{msg}, nil + } + + // Otherwise walk to the first height whose set differs from the one currently + // trusted, emit that header, and continue from there with the set it carries. + var chain []*types.Header + current := trusted + height := from + + for height < to { + next, nextSet, err := p.firstChange(ctx, current, height, to) + if err != nil { + return nil, err + } + msg, err := headerMessage(next) + if err != nil { + return nil, err + } + chain = append(chain, msg) + + height = next.Number.Uint64() + current = nextSet + + if height == to { + return chain, nil + } + if sameSet(current, targetSet) { + // Everything from here to the target shares one set, so the target + // verifies against it directly. + msg, err := headerMessage(target) + if err != nil { + return nil, err + } + return append(chain, msg), nil + } + } + + return chain, nil +} + +// firstChange binary-searches (low, high] for the first header whose validator set +// differs from current, and returns it with that set. +func (p *Prover) firstChange( + ctx context.Context, + current []common.Address, + low, high uint64, +) (*ethtypes.Header, []common.Address, error) { + lo, hi := low+1, high + + for lo < hi { + mid := lo + (hi-lo)/2 + h, err := p.chain.HeaderByNumber(ctx, mid) + if err != nil { + return nil, nil, fmt.Errorf("prover: fetching header %d: %w", mid, err) + } + set, err := types.HeaderValidators(h) + if err != nil { + return nil, nil, err + } + if sameSet(set, current) { + lo = mid + 1 + } else { + hi = mid + } + } + + h, err := p.chain.HeaderByNumber(ctx, lo) + if err != nil { + return nil, nil, fmt.Errorf("prover: fetching header %d: %w", lo, err) + } + set, err := types.HeaderValidators(h) + if err != nil { + return nil, nil, err + } + return h, set, nil +} + +// PacketCommitmentProof proves the commitment for (clientID, sequence) as of height. +func (p *Prover) PacketCommitmentProof( + ctx context.Context, + clientID string, + sequence, height uint64, +) (*types.StorageProof, error) { + return p.slotProof(ctx, types.PacketCommitmentSlot(clientID, sequence), height) +} + +// PacketAckProof proves the acknowledgement commitment for (clientID, sequence). +func (p *Prover) PacketAckProof( + ctx context.Context, + clientID string, + sequence, height uint64, +) (*types.StorageProof, error) { + return p.slotProof(ctx, types.PacketAckSlot(clientID, sequence), height) +} + +// PacketReceiptProof proves the receipt for (clientID, sequence). It is also the +// proof of absence used to justify a timeout, since an unwritten receipt is exactly +// what a timeout asserts. +func (p *Prover) PacketReceiptProof( + ctx context.Context, + clientID string, + sequence, height uint64, +) (*types.StorageProof, error) { + return p.slotProof(ctx, types.PacketReceiptSlot(clientID, sequence), height) +} + +func (p *Prover) slotProof(ctx context.Context, slot common.Hash, height uint64) (*types.StorageProof, error) { + res, err := p.chain.ProofAt(ctx, p.contract, []common.Hash{slot}, height) + if err != nil { + return nil, fmt.Errorf("prover: eth_getProof at height %d: %w", height, err) + } + if len(res.StorageProofs) != 1 { + return nil, fmt.Errorf("prover: expected 1 storage proof, got %d", len(res.StorageProofs)) + } + + proof := &types.StorageProof{ + AccountProof: res.AccountProof, + StorageProof: res.StorageProofs[0], + } + if err := proof.Validate(); err != nil { + return nil, err + } + return proof, nil +} + +func sameSet(a, b []common.Address) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/x/qbftclient/prover/prover_test.go b/x/qbftclient/prover/prover_test.go new file mode 100644 index 00000000..524a3ab5 --- /dev/null +++ b/x/qbftclient/prover/prover_test.go @@ -0,0 +1,234 @@ +package prover_test + +import ( + "context" + "crypto/ecdsa" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/peersyst/cbdc-node/x/qbftclient/prover" + qbfttestutil "github.com/peersyst/cbdc-node/x/qbftclient/testutil" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +const ( + trustingPeriod = 14 * 24 * time.Hour + maxClockDrift = 10 * time.Second +) + +var contract = common.HexToAddress("0x00000000000000000000000000000000cafebabe") + +// fakeChain is a Besu endpoint standing in for a real one: a fixed set of headers +// and one state, enough to exercise everything the prover does with an RPC. +type fakeChain struct { + headers map[uint64]*ethtypes.Header + state qbfttestutil.State + t *testing.T + // calls counts HeaderByNumber requests, so tests can assert the prover is not + // walking the chain block by block. + calls int +} + +func (c *fakeChain) HeaderByNumber(_ context.Context, height uint64) (*ethtypes.Header, error) { + c.calls++ + h, ok := c.headers[height] + if !ok { + return nil, fmt.Errorf("no header at %d", height) + } + return h, nil +} + +func (c *fakeChain) ProofAt(_ context.Context, account common.Address, slots []common.Hash, _ uint64) (*prover.AccountProof, error) { + if account != c.state.Contract { + return nil, fmt.Errorf("unexpected account %s", account) + } + out := &prover.AccountProof{AccountProof: c.state.AccountProof} + for _, slot := range slots { + out.StorageProofs = append(out.StorageProofs, c.state.StorageProof(c.t, slot)) + } + return out, nil +} + +// chainWithRotation builds heights 100..110 where the validator set changes at 105: +// blocks up to 104 carry set A, 105 is sealed by A but announces B, and 106 onward +// carry B. +func chainWithRotation(t *testing.T, setA, setB []*ecdsa.PrivateKey, state qbfttestutil.State) *fakeChain { + t.Helper() + + c := &fakeChain{headers: map[uint64]*ethtypes.Header{}, state: state, t: t} + for h := uint64(100); h <= 110; h++ { + signers, carried := setA, qbfttestutil.Validators(setA) + switch { + case h == 105: + // The rotation block: sealed by the outgoing set, announcing the new one. + carried = qbfttestutil.Validators(setB) + case h > 105: + signers, carried = setB, qbfttestutil.Validators(setB) + } + c.headers[h] = qbfttestutil.SealedHeader(t, signers, qbfttestutil.HeaderOpts{ + Height: h, Time: 1_000 + h, Seals: 3, Carried: carried, StateRoot: state.Root, + }) + } + return c +} + +func TestHeaderMessage(t *testing.T) { + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{}) + chain := chainWithRotation(t, keys, keys, state) + + msg, err := prover.New(chain, contract).HeaderMessage(context.Background(), 101) + if err != nil { + t.Fatalf("HeaderMessage: %v", err) + } + + // The message must decode back to the same block the chain served. + decoded, err := msg.EthHeader() + if err != nil { + t.Fatalf("EthHeader: %v", err) + } + if decoded.Number.Uint64() != 101 { + t.Errorf("height = %d, want 101", decoded.Number) + } +} + +// The common case: nothing changed, so one header carries the client all the way. +func TestUpdateChain_NoSetChange(t *testing.T) { + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{}) + chain := chainWithRotation(t, keys, keys, state) + + got, err := prover.New(chain, contract).UpdateChain( + context.Background(), qbfttestutil.Validators(keys), 100, 104) + if err != nil { + t.Fatalf("UpdateChain: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d headers, want 1", len(got)) + } + if chain.calls > 2 { + t.Errorf("took %d header fetches for an unchanged set; should be a single probe", chain.calls) + } +} + +// The case that makes this function exist: a set change between the trusted height +// and the target cannot be skipped, because the client checks each header against +// the set the previous one carried. The produced chain must verify link by link. +func TestUpdateChain_AcrossSetChange(t *testing.T) { + setA := qbfttestutil.Keys(t, 4) + setB := append(append([]*ecdsa.PrivateKey{}, setA[:2]...), qbfttestutil.Keys(t, 2)...) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{}) + chain := chainWithRotation(t, setA, setB, state) + + got, err := prover.New(chain, contract).UpdateChain( + context.Background(), qbfttestutil.Validators(setA), 100, 110) + if err != nil { + t.Fatalf("UpdateChain: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d headers, want 2 (the rotation block and the target)", len(got)) + } + + // Replay the chain exactly as the light client would. + trusted := types.TrustedState{ + Height: 100, + Timestamp: time.Unix(1_100, 0).UTC(), + Validators: qbfttestutil.Validators(setA), + } + for i, msg := range got { + h, err := msg.EthHeader() + if err != nil { + t.Fatalf("header %d: %v", i, err) + } + now := time.Unix(int64(h.Time), 0).UTC() + if err := types.VerifyHeader(trusted, h, now, trustingPeriod, maxClockDrift); err != nil { + t.Fatalf("header %d (height %d) does not verify against the previous set: %v", i, h.Number, err) + } + if trusted, err = types.NextTrustedState(h); err != nil { + t.Fatalf("advancing after header %d: %v", i, err) + } + } + + if trusted.Height != 110 { + t.Errorf("chain ended at height %d, want 110", trusted.Height) + } + // The set must have rotated to B by the end. + if got, want := trusted.Validators, qbfttestutil.Validators(setB); !equalAddrs(got, want) { + t.Errorf("final set = %v, want %v", got, want) + } +} + +func TestUpdateChain_RejectsBackwardsTarget(t *testing.T) { + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{}) + chain := chainWithRotation(t, keys, keys, state) + + if _, err := prover.New(chain, contract).UpdateChain( + context.Background(), qbfttestutil.Validators(keys), 105, 105); err == nil { + t.Error("a target at or below the trusted height must be rejected") + } +} + +// End to end: the proof the prover produces must satisfy the verifier the client +// uses. This is the pairing DEC-7 exists to protect — one package, one test. +func TestPacketCommitmentProof_VerifiesAgainstTheClient(t *testing.T) { + const clientID = "qbft-0" + const sequence = 7 + + commitment := common.HexToHash("0x00ddeeff00112233445566778899aabbccddeeff00112233445566778899aabb") + slot := types.PacketCommitmentSlot(clientID, sequence) + + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{slot: commitment.Bytes()}) + chain := chainWithRotation(t, keys, keys, state) + + proof, err := prover.New(chain, contract).PacketCommitmentProof(context.Background(), clientID, sequence, 101) + if err != nil { + t.Fatalf("PacketCommitmentProof: %v", err) + } + + if err := types.VerifyCommitment( + state.Root, contract, slot, commitment, + proof.AccountProof, proof.StorageProof, + ); err != nil { + t.Errorf("the prover's proof must satisfy the client's verifier: %v", err) + } +} + +// An unwritten receipt is what a timeout asserts, so its absence proof has to work. +func TestPacketReceiptProof_ProvesAbsence(t *testing.T) { + const clientID = "qbft-0" + const sequence = 7 + + keys := qbfttestutil.Keys(t, 4) + state := qbfttestutil.NewState(t, contract, map[common.Hash][]byte{}) + chain := chainWithRotation(t, keys, keys, state) + + proof, err := prover.New(chain, contract).PacketReceiptProof(context.Background(), clientID, sequence, 101) + if err != nil { + t.Fatalf("PacketReceiptProof: %v", err) + } + + if err := types.VerifyStorageAbsent( + state.Root, contract, types.PacketReceiptSlot(clientID, sequence), + proof.AccountProof, proof.StorageProof, + ); err != nil { + t.Errorf("an unwritten receipt must prove absent: %v", err) + } +} + +func equalAddrs(a, b []common.Address) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} From 8de70174ec6e33cfb3da9214d1ec65068b01187a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 08:30:42 +0200 Subject: [PATCH 07/61] feat(cmd): v2relay break-glass tool and three-chain devnet harness v2relay builds a MsgRecvPacket with a commitment proof and emits it unsigned. It is a proof of capability and a manual break-glass tool, not a production relayer: one packet on demand, no event loop, no ack leg, no retries. scripts/ibcv2-devnet stands up two cbdc-node chains plus an ibc-go simapp on one command, which is what closed the gap nothing in this repo could test: a real relayer moving real packets. Two settings there are not optional and are baked into up.sh -- TZ=UTC, because the relayer writes packet deadlines as local wall-clock into a timestamp-without-time-zone column, and pinning the relayer API off port 9000, which proof-api also binds undocumented. local-node.sh gains an UNBONDING_TIME override. The 60s default caps a tendermint client's trusting period below 60s, so clients expire almost immediately and the stock localnet cannot hold an IBC client at all. The default is unchanged; IBC work runs as UNBONDING_TIME=1814400s ./local-node.sh. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/v2relay/main.go | 147 +++ local-node.sh | 12 +- scripts/ibcv2-devnet/.gitignore | 11 + scripts/ibcv2-devnet/README.md | 55 + scripts/ibcv2-devnet/chain-c.sh | 75 ++ scripts/ibcv2-devnet/mercury-config.toml | 37 + .../ibcv2-devnet/mercury-keys/chain-a.toml | 1 + .../ibcv2-devnet/mercury-keys/chain-b.toml | 1 + scripts/ibcv2-devnet/mercury-native.toml | 37 + scripts/ibcv2-devnet/mercury-test-tx.txt | 1 + scripts/ibcv2-devnet/mkclient/go.mod | 173 +++ scripts/ibcv2-devnet/mkclient/go.sum | 1100 +++++++++++++++++ scripts/ibcv2-devnet/mkclient/main.go | 94 ++ scripts/ibcv2-devnet/path.txt | 1 + scripts/ibcv2-devnet/simd | 2 + scripts/ibcv2-devnet/transfer.sh | 75 ++ scripts/ibcv2-devnet/two-chains.sh | 135 ++ scripts/ibcv2-devnet/up.sh | 261 ++++ 18 files changed, 2217 insertions(+), 1 deletion(-) create mode 100644 cmd/v2relay/main.go create mode 100644 scripts/ibcv2-devnet/.gitignore create mode 100644 scripts/ibcv2-devnet/README.md create mode 100755 scripts/ibcv2-devnet/chain-c.sh create mode 100644 scripts/ibcv2-devnet/mercury-config.toml create mode 100644 scripts/ibcv2-devnet/mercury-keys/chain-a.toml create mode 100644 scripts/ibcv2-devnet/mercury-keys/chain-b.toml create mode 100644 scripts/ibcv2-devnet/mercury-native.toml create mode 100644 scripts/ibcv2-devnet/mercury-test-tx.txt create mode 100644 scripts/ibcv2-devnet/mkclient/go.mod create mode 100644 scripts/ibcv2-devnet/mkclient/go.sum create mode 100644 scripts/ibcv2-devnet/mkclient/main.go create mode 100644 scripts/ibcv2-devnet/path.txt create mode 100755 scripts/ibcv2-devnet/simd create mode 100755 scripts/ibcv2-devnet/transfer.sh create mode 100755 scripts/ibcv2-devnet/two-chains.sh create mode 100755 scripts/ibcv2-devnet/up.sh diff --git a/cmd/v2relay/main.go b/cmd/v2relay/main.go new file mode 100644 index 00000000..eab42728 --- /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 licence bar +// (a commercial licence 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 cbdcd: +// +// cbdcd tx sign unsigned.json --from alice ... --output-document signed.json +// cbdcd 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/local-node.sh b/local-node.sh index 93b3abec..5bd78be6 100755 --- a/local-node.sh +++ b/local-node.sh @@ -1,3 +1,8 @@ +#!/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. + CHAINID="cbdc_1449999-1" MONIKER="localnet" # Remember to change to other types of keyring like 'file' in-case exposing to outside world, @@ -15,6 +20,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 @@ -44,7 +54,7 @@ jq '.app_state["gov"]["params"]["min_deposit"][0]["amount"]="1"' "$GENESIS" >"$T 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" diff --git a/scripts/ibcv2-devnet/.gitignore b/scripts/ibcv2-devnet/.gitignore new file mode 100644 index 00000000..5da0673a --- /dev/null +++ b/scripts/ibcv2-devnet/.gitignore @@ -0,0 +1,11 @@ +# generated at run time +bin/ +cs/ +logs/ +chain-a/ +chain-b/ +chain-c/ +*.env +ibcv2keys.json +relayer-config.yml +proof-api.json diff --git a/scripts/ibcv2-devnet/README.md b/scripts/ibcv2-devnet/README.md new file mode 100644 index 00000000..f34c45fc --- /dev/null +++ b/scripts/ibcv2-devnet/README.md @@ -0,0 +1,55 @@ +# IBC v2 devnet + +Three local chains, three IBC v2 paths, and a real relayer. Built to close the +one gap nothing in this repo can test: **the relayer**. + +``` +chain-a ── cbdc-node ────┐ + ├── IBC v2 ── chain-c ── ibc-go simapp (cross-implementation) +chain-b ── cbdc-node ────┘ + + cosmos/ibc-relayer ── gRPC ── proof-api ── postgres +``` + +Findings and the full verification matrix are in +[`docs/ibc-v2-devnet-findings.md`](../../docs/ibc-v2-devnet-findings.md). + +## Prerequisites + +| What | How | +|---|---| +| `bin/cbdcd` | `make build` in the repo root | +| relayer binary | `git clone https://github.com/cosmos/ibc-relayer && make build` — **source-available evaluation licence, non-production** | +| `proof-api:local` image | build `programs/proof-api/Dockerfile` from `cosmos/solidity-ibc-eureka` | +| `mkclient` | `cd mkclient && go build -o ../bin/mkclient .` | +| docker, jq, grpcurl | — | + +The proof API needs a C++ toolchain to build natively (`gcc-c++` on Fedora); +building it in Docker avoids that. + +## Run + +```sh +RELAYER_BIN=/path/to/relayer ./up.sh +``` + +Brings up all three chains from clean genesis, creates the three v2 paths, +generates the relayer and proof-API configs, and starts everything. Client ids +and addresses land in `paths.env`. + +```sh +./transfer.sh a b 1000000000000000axrp # cbdc-node -> cbdc-node +./transfer.sh a c 1000000000000000axrp # cbdc-node -> simapp +./transfer.sh b c 1000000000000000ibc/ # multi-hop, voucher of a voucher +``` + +Teardown: `./two-chains.sh stop; ./chain-c.sh stop; pkill -x relayer; +docker rm -f proof-api ibcv2-postgres`. + +## Two settings that are not optional + +- **`TZ=UTC` on the relayer.** It writes packet deadlines as local wall-clock + into a `timestamp without time zone` column, so a non-UTC host skews every + timeout by its offset. `up.sh` sets this. +- **Relayer API on 9001, not 9000.** The proof API also binds 9000, which is + undocumented and is the relayer's own default. Whichever starts second dies. diff --git a/scripts/ibcv2-devnet/chain-c.sh b/scripts/ibcv2-devnet/chain-c.sh new file mode 100755 index 00000000..3177ae84 --- /dev/null +++ b/scripts/ibcv2-devnet/chain-c.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Chain C: stock ibc-go simd v10.3.0, as a cross-implementation counterparty. +# Deliberately NOT cbdc-node: no EVM, no erc20/cbdc/poa modules, plain secp256k1. +set -euo pipefail + +DEVNET="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOME_DIR="$DEVNET/chain-c" +IMG="${SIMD_IMAGE:-ghcr.io/cosmos/ibc-go-simd:main}" +CHAINID="simd-1" +# ports offset +200 from chain A +RPC_PORT=26857 +GRPC_PORT=9290 +P2P_PORT=26856 +API_PORT=1517 + +simd() { docker run --rm --user "$(id -u):$(id -g)" -e HOME=/data -v "$HOME_DIR:/data" "$IMG" "$@" --home /data; } + +case "${1:-all}" in +init) + rm -rf "$HOME_DIR"; mkdir -p "$HOME_DIR" + simd init chain-c --chain-id "$CHAINID" >/dev/null 2>&1 + simd keys add alice --keyring-backend test >/dev/null 2>&1 + simd keys add relayer --keyring-backend test >/dev/null 2>&1 + ALICE=$(simd keys show alice -a --keyring-backend test) + RELAYER=$(simd keys show relayer -a --keyring-backend test) + + G="$HOME_DIR/config/genesis.json" + # stake is the bond denom; give both accounts plenty + simd genesis add-genesis-account "$ALICE" 100000000000000stake --keyring-backend test + simd genesis add-genesis-account "$RELAYER" 100000000000000stake --keyring-backend test + simd genesis gentx alice 1000000000stake --chain-id "$CHAINID" --keyring-backend test >/dev/null 2>&1 + simd genesis collect-gentxs >/dev/null 2>&1 + + # short voting period so gov steps are scriptable; long unbonding so clients live + tmp=$(mktemp) + jq '.app_state.gov.params.voting_period="30s" + | .app_state.gov.params.expedited_voting_period="15s" + | .app_state.gov.params.min_deposit[0].denom="stake" + | .app_state.gov.params.min_deposit[0].amount="1" + | .app_state.staking.params.unbonding_time="1814400s"' "$G" >"$tmp" && mv "$tmp" "$G" + + sed -i "s|laddr = \"tcp://127.0.0.1:26657\"|laddr = \"tcp://0.0.0.0:$RPC_PORT\"|" "$HOME_DIR/config/config.toml" + sed -i "s|laddr = \"tcp://0.0.0.0:26656\"|laddr = \"tcp://0.0.0.0:$P2P_PORT\"|" "$HOME_DIR/config/config.toml" + sed -i "s|prometheus_listen_addr = \":26660\"|prometheus_listen_addr = \":26860\"|" "$HOME_DIR/config/config.toml" + sed -i "s|pprof_laddr = \"localhost:6060\"|pprof_laddr = \"localhost:6260\"|" "$HOME_DIR/config/config.toml" + sed -i "s|^indexer = .*|indexer = \"kv\"|" "$HOME_DIR/config/config.toml" + sed -i "s|address = \"tcp://localhost:1317\"|address = \"tcp://0.0.0.0:$API_PORT\"|" "$HOME_DIR/config/app.toml" + sed -i "s|address = \"localhost:9090\"|address = \"0.0.0.0:$GRPC_PORT\"|" "$HOME_DIR/config/app.toml" + sed -i 's/^enable = false/enable = true/' "$HOME_DIR/config/app.toml" + sed -i 's/minimum-gas-prices = ".*"/minimum-gas-prices = "0stake"/' "$HOME_DIR/config/app.toml" + + cat >"$DEVNET/chain-c.env" </dev/null 2>&1 || true + docker run -d --name chain-c --network host --user "$(id -u):$(id -g)" -e HOME=/data -v "$HOME_DIR:/data" "$IMG" \ + start --home /data --grpc.address "0.0.0.0:$GRPC_PORT" >/dev/null + echo " chain-c started" + ;; +stop) + docker rm -f chain-c >/dev/null 2>&1 || true + ;; +all) + "$0" stop; "$0" init; "$0" start + ;; +esac diff --git a/scripts/ibcv2-devnet/mercury-config.toml b/scripts/ibcv2-devnet/mercury-config.toml new file mode 100644 index 00000000..18c440e3 --- /dev/null +++ b/scripts/ibcv2-devnet/mercury-config.toml @@ -0,0 +1,37 @@ +[[chains]] +type = "cosmos" +chain_id = "cbdc_1449999-1" +rpc_addr = "http://127.0.0.1:26657" +grpc_addr = "http://127.0.0.1:9090" +account_prefix = "ethm" +key_name = "mrly" +key_file = "/keys/chain-a.toml" +gas_multiplier = 1.5 +max_gas = 3000000 +default_gas = 800000 + +[chains.gas_price] +amount = 0.0 +denom = "axrp" + +[[chains]] +type = "cosmos" +chain_id = "cbdc_1449998-1" +rpc_addr = "http://127.0.0.1:26757" +grpc_addr = "http://127.0.0.1:9190" +account_prefix = "ethm" +key_name = "mrly" +key_file = "/keys/chain-b.toml" +gas_multiplier = 1.5 +max_gas = 3000000 +default_gas = 800000 + +[chains.gas_price] +amount = 0.0 +denom = "axrp" + +[[relays]] +src_chain = "cbdc_1449999-1" +dst_chain = "cbdc_1449998-1" +src_client_id = "07-tendermint-0" +dst_client_id = "07-tendermint-0" diff --git a/scripts/ibcv2-devnet/mercury-keys/chain-a.toml b/scripts/ibcv2-devnet/mercury-keys/chain-a.toml new file mode 100644 index 00000000..237f7c8b --- /dev/null +++ b/scripts/ibcv2-devnet/mercury-keys/chain-a.toml @@ -0,0 +1 @@ +secret_key = "49a426f90bf257c58dc360feb3abfded8af7a4d0f31ea54cdbb95d756d69b31d" diff --git a/scripts/ibcv2-devnet/mercury-keys/chain-b.toml b/scripts/ibcv2-devnet/mercury-keys/chain-b.toml new file mode 100644 index 00000000..96d2d454 --- /dev/null +++ b/scripts/ibcv2-devnet/mercury-keys/chain-b.toml @@ -0,0 +1 @@ +secret_key = "11e24707524b9fab45965da5e870eed19c43abd3def71eaa90d342663018a485" diff --git a/scripts/ibcv2-devnet/mercury-native.toml b/scripts/ibcv2-devnet/mercury-native.toml new file mode 100644 index 00000000..e7b8f1b7 --- /dev/null +++ b/scripts/ibcv2-devnet/mercury-native.toml @@ -0,0 +1,37 @@ +[[chains]] +type = "cosmos" +chain_id = "cbdc_1449999-1" +rpc_addr = "http://127.0.0.1:26657" +grpc_addr = "http://127.0.0.1:9090" +account_prefix = "ethm" +key_name = "mrly" +key_file = "/home/alvaro-laptop/work/cbdc/cbdc-node/scripts/ibcv2-devnet/mercury-keys/chain-a.toml" +gas_multiplier = 1.5 +max_gas = 3000000 +default_gas = 800000 + +[chains.gas_price] +amount = 0.0 +denom = "axrp" + +[[chains]] +type = "cosmos" +chain_id = "cbdc_1449998-1" +rpc_addr = "http://127.0.0.1:26757" +grpc_addr = "http://127.0.0.1:9190" +account_prefix = "ethm" +key_name = "mrly" +key_file = "/home/alvaro-laptop/work/cbdc/cbdc-node/scripts/ibcv2-devnet/mercury-keys/chain-b.toml" +gas_multiplier = 1.5 +max_gas = 3000000 +default_gas = 800000 + +[chains.gas_price] +amount = 0.0 +denom = "axrp" + +[[relays]] +src_chain = "cbdc_1449999-1" +dst_chain = "cbdc_1449998-1" +src_client_id = "07-tendermint-0" +dst_client_id = "07-tendermint-0" diff --git a/scripts/ibcv2-devnet/mercury-test-tx.txt b/scripts/ibcv2-devnet/mercury-test-tx.txt new file mode 100644 index 00000000..2db739a9 --- /dev/null +++ b/scripts/ibcv2-devnet/mercury-test-tx.txt @@ -0,0 +1 @@ +C0D11B71E807704201722E8A48BF030048C123B3F50757D68ABD98D003E66C45 diff --git a/scripts/ibcv2-devnet/mkclient/go.mod b/scripts/ibcv2-devnet/mkclient/go.mod new file mode 100644 index 00000000..93207905 --- /dev/null +++ b/scripts/ibcv2-devnet/mkclient/go.mod @@ -0,0 +1,173 @@ +module ibcv2tool + +go 1.24 + +require ( + github.com/cometbft/cometbft v0.38.21 + github.com/cosmos/cosmos-sdk v0.53.6 + github.com/cosmos/ibc-go/v10 v10.3.1-0.20250909102629-ed3b125c7b6f +) + +require ( + cosmossdk.io/api v0.9.2 // indirect + cosmossdk.io/collections v1.3.1 // indirect + cosmossdk.io/core v0.11.3 // indirect + cosmossdk.io/depinject v1.2.1 // indirect + cosmossdk.io/errors v1.0.2 // indirect + cosmossdk.io/log v1.6.1 // indirect + cosmossdk.io/math v1.5.3 // indirect + cosmossdk.io/schema v1.1.0 // indirect + cosmossdk.io/store v1.1.2 // indirect + cosmossdk.io/x/tx v0.14.0 // indirect + cosmossdk.io/x/upgrade v0.2.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/99designs/keyring v1.2.2 // indirect + github.com/DataDog/datadog-go v4.8.3+incompatible // indirect + github.com/DataDog/zstd v1.5.7 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bgentry/speakeasy v0.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cockroachdb/errors v1.12.0 // indirect + github.com/cockroachdb/fifo v0.0.0-20240616162244-4768e80dfb9a // indirect + github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect + github.com/cockroachdb/pebble v1.1.5 // indirect + github.com/cockroachdb/redact v1.1.6 // indirect + github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect + github.com/cometbft/cometbft-db v0.14.1 // indirect + github.com/cosmos/btcutil v1.0.5 // indirect + github.com/cosmos/cosmos-db v1.1.3 // indirect + github.com/cosmos/cosmos-proto v1.0.0-beta.5 // indirect + github.com/cosmos/go-bip39 v1.0.0 // indirect + github.com/cosmos/gogogateway v1.2.0 // indirect + github.com/cosmos/gogoproto v1.7.2 // 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/danieljoos/wincred v1.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/desertbit/timer v1.0.1 // indirect + github.com/dgraph-io/badger/v4 v4.2.0 // indirect + github.com/dgraph-io/ristretto v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dvsekhvalnov/jose2go v1.7.0 // indirect + github.com/emicklei/dot v1.6.2 // indirect + github.com/fatih/color v1.17.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/getsentry/sentry-go v0.35.0 // indirect + github.com/go-kit/kit v0.13.0 // indirect + github.com/go-kit/log v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gogo/googleapis v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/flatbuffers v24.3.25+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/orderedcode v0.0.1 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-metrics v0.5.4 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huandu/skiplist v1.2.1 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/improbable-eng/grpc-web v0.15.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmhodges/levigo v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/linxGnu/grocksdb v1.9.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/highwayhash v1.0.3 // indirect + github.com/mtibben/percent v0.2.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sasha-s/go-deadlock v0.3.5 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/tendermint/go-amino v0.16.0 // indirect + github.com/tidwall/btree v1.7.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/zondax/golem v0.27.0 // indirect + github.com/zondax/hid v0.9.2 // indirect + github.com/zondax/ledger-go v1.0.1 // indirect + go.etcd.io/bbolt v1.4.0-alpha.1 // indirect + go.opencensus.io v0.24.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.17.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect + nhooyr.io/websocket v1.8.11 // indirect + pgregory.net/rapid v1.2.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace ( + cosmossdk.io/core => cosmossdk.io/core v0.11.3 + github.com/99designs/keyring => github.com/cosmos/keyring v1.2.0 + github.com/cosmos/cosmos-sdk => github.com/xrplevm/cosmos-sdk v0.53.6-xrplevm.1 + github.com/ethereum/go-ethereum => github.com/cosmos/go-ethereum v0.0.0-20250806193535-2fc7571efa91 + github.com/syndtr/goleveldb => github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 +) diff --git a/scripts/ibcv2-devnet/mkclient/go.sum b/scripts/ibcv2-devnet/mkclient/go.sum new file mode 100644 index 00000000..90a8ca1a --- /dev/null +++ b/scripts/ibcv2-devnet/mkclient/go.sum @@ -0,0 +1,1100 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= +cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute v1.38.0 h1:MilCLYQW2m7Dku8hRIIKo4r0oKastlD74sSu16riYKs= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/storage v1.49.0 h1:zenOPBOWHCnojRd9aJZAyQXBYqkJkdQS42dxL55CIMw= +cloud.google.com/go/storage v1.49.0/go.mod h1:k1eHhhpLvrPjVGfo0mOUPEJ4Y2+a/Hv5PiwehZI9qGU= +cosmossdk.io/api v0.9.2 h1:9i9ptOBdmoIEVEVWLtYYHjxZonlF/aOVODLFaxpmNtg= +cosmossdk.io/api v0.9.2/go.mod h1:CWt31nVohvoPMTlPv+mMNCtC0a7BqRdESjCsstHcTkU= +cosmossdk.io/collections v1.3.1 h1:09e+DUId2brWsNOQ4nrk+bprVmMUaDH9xvtZkeqIjVw= +cosmossdk.io/collections v1.3.1/go.mod h1:ynvkP0r5ruAjbmedE+vQ07MT6OtJ0ZIDKrtJHK7Q/4c= +cosmossdk.io/core v0.11.3 h1:mei+MVDJOwIjIniaKelE3jPDqShCc/F4LkNNHh+4yfo= +cosmossdk.io/core v0.11.3/go.mod h1:9rL4RE1uDt5AJ4Tg55sYyHWXA16VmpHgbe0PbJc6N2Y= +cosmossdk.io/depinject v1.2.1 h1:eD6FxkIjlVaNZT+dXTQuwQTKZrFZ4UrfCq1RKgzyhMw= +cosmossdk.io/depinject v1.2.1/go.mod h1:lqQEycz0H2JXqvOgVwTsjEdMI0plswI7p6KX+MVqFOM= +cosmossdk.io/errors v1.0.2 h1:wcYiJz08HThbWxd/L4jObeLaLySopyyuUFB5w4AGpCo= +cosmossdk.io/errors v1.0.2/go.mod h1:0rjgiHkftRYPj//3DrD6y8hcm40HcPv/dR4R/4efr0k= +cosmossdk.io/log v1.6.1 h1:YXNwAgbDwMEKwDlCdH8vPcoggma48MgZrTQXCfmMBeI= +cosmossdk.io/log v1.6.1/go.mod h1:gMwsWyyDBjpdG9u2avCFdysXqxq28WJapJvu+vF1y+E= +cosmossdk.io/math v1.5.3 h1:WH6tu6Z3AUCeHbeOSHg2mt9rnoiUWVWaQ2t6Gkll96U= +cosmossdk.io/math v1.5.3/go.mod h1:uqcZv7vexnhMFJF+6zh9EWdm/+Ylyln34IvPnBauPCQ= +cosmossdk.io/schema v1.1.0 h1:mmpuz3dzouCoyjjcMcA/xHBEmMChN+EHh8EHxHRHhzE= +cosmossdk.io/schema v1.1.0/go.mod h1:Gb7pqO+tpR+jLW5qDcNOSv0KtppYs7881kfzakguhhI= +cosmossdk.io/store v1.1.2 h1:3HOZG8+CuThREKv6cn3WSohAc6yccxO3hLzwK6rBC7o= +cosmossdk.io/store v1.1.2/go.mod h1:60rAGzTHevGm592kFhiUVkNC9w7gooSEn5iUBPzHQ6A= +cosmossdk.io/x/tx v0.14.0 h1:hB3O25kIcyDW/7kMTLMaO8Ripj3yqs5imceVd6c/heA= +cosmossdk.io/x/tx v0.14.0/go.mod h1:Tn30rSRA1PRfdGB3Yz55W4Sn6EIutr9xtMKSHij+9PM= +cosmossdk.io/x/upgrade v0.2.0 h1:ZHy0xny3wBCSLomyhE06+UmQHWO8cYlVYjfFAJxjz5g= +cosmossdk.io/x/upgrade v0.2.0/go.mod h1:DXDtkvi//TrFyHWSOaeCZGBoiGAE6Rs8/0ABt2pcDD0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q= +github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 h1:UQ0AhxogsIRZDkElkblfnwjc3IaltCm2HUMvezQaL7s= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 h1:8nn+rsCvTq9axyEh382S0PFLBeaFwNsT43IrPWzctRU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/adlio/schema v1.3.6 h1:k1/zc2jNfeiZBA5aFTRy37jlBIuCkXCm0XmvpzCKI9I= +github.com/adlio/schema v1.3.6/go.mod h1:qkxwLgPBd1FgLRHYVCmQT/rrBr3JH38J9LjmVzWNudg= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= +github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= +github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bits-and-blooms/bitset v1.24.3 h1:Bte86SlO3lwPQqww+7BE9ZuUCKIjfqnG5jtEyqA9y9Y= +github.com/bits-and-blooms/bitset v1.24.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU= +github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ= +github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= +github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +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.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= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cockroachdb/apd/v2 v2.0.2 h1:weh8u7Cneje73dDh+2tEVLUvyBc89iwepWCD8b8034E= +github.com/cockroachdb/apd/v2 v2.0.2/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= +github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= +github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g= +github.com/cockroachdb/fifo v0.0.0-20240616162244-4768e80dfb9a h1:f52TdbU4D5nozMAhO9TvTJ2ZMCXtN4VIAmfrrZ0JXQ4= +github.com/cockroachdb/fifo v0.0.0-20240616162244-4768e80dfb9a/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.6 h1:zXJBwDZ84xJNlHl1rMyCojqyIxv+7YUpQiJLQ7n4314= +github.com/cockroachdb/redact v1.1.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/cometbft/cometbft v0.38.21 h1:qcIJSH9LiwU5s6ZgKR5eRbsLNucbubfraDs5bzgjtOI= +github.com/cometbft/cometbft v0.38.21/go.mod h1:UCu8dlHqvkAsmAFmWDRWNZJPlu6ya2fTWZlDrWsivwo= +github.com/cometbft/cometbft-db v0.14.1 h1:SxoamPghqICBAIcGpleHbmoPqy+crij/++eZz3DlerQ= +github.com/cometbft/cometbft-db v0.14.1/go.mod h1:KHP1YghilyGV/xjD5DP3+2hyigWx0WTp9X+0Gnx0RxQ= +github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cosmos/btcutil v1.0.5 h1:t+ZFcX77LpKtDBhjucvnOH8C2l2ioGsBNEQ3jef8xFk= +github.com/cosmos/btcutil v1.0.5/go.mod h1:IyB7iuqZMJlthe2tkIFL33xPyzbFYP0XVdS8P5lUPis= +github.com/cosmos/cosmos-db v1.1.3 h1:7QNT77+vkefostcKkhrzDK9uoIEryzFrU9eoMeaQOPY= +github.com/cosmos/cosmos-db v1.1.3/go.mod h1:kN+wGsnwUJZYn8Sy5Q2O0vCYA99MJllkKASbs6Unb9U= +github.com/cosmos/cosmos-proto v1.0.0-beta.5 h1:eNcayDLpip+zVLRLYafhzLvQlSmyab+RC5W7ZfmxJLA= +github.com/cosmos/cosmos-proto v1.0.0-beta.5/go.mod h1:hQGLpiIUloJBMdQMMWb/4wRApmI9hjHH05nefC0Ojec= +github.com/cosmos/go-bip39 v1.0.0 h1:pcomnQdrdH22njcAatO0yWojsUnCO3y2tNoV1cb6hHY= +github.com/cosmos/go-bip39 v1.0.0/go.mod h1:RNJv0H/pOIVgxw6KS7QeX2a0Uo0aKUlfhZ4xuwvCdJw= +github.com/cosmos/go-ethereum v0.0.0-20250806193535-2fc7571efa91 h1:kgu2NkKzSeJJlVsKeS+KbdzfUeaFqrqmmhwixd/PNH4= +github.com/cosmos/go-ethereum v0.0.0-20250806193535-2fc7571efa91/go.mod h1:X5CIOyo8SuK1Q5GnaEizQVLHT/DfsiGWuNeVdQcEMNA= +github.com/cosmos/gogogateway v1.2.0 h1:Ae/OivNhp8DqBi/sh2A8a1D0y638GpL3tkmLQAiKxTE= +github.com/cosmos/gogogateway v1.2.0/go.mod h1:iQpLkGWxYcnCdz5iAdLcRBSw3h7NXeOkZ4GUkT+tbFI= +github.com/cosmos/gogoproto v1.4.2/go.mod h1:cLxOsn1ljAHSV527CHOtaIP91kK6cCrZETRBrkzItWU= +github.com/cosmos/gogoproto v1.7.2 h1:5G25McIraOC0mRFv9TVO139Uh3OklV2hczr13KKVHCA= +github.com/cosmos/gogoproto v1.7.2/go.mod h1:8S7w53P1Y1cHwND64o0BnArT6RmdgIvsBuco6uTllsk= +github.com/cosmos/iavl v1.2.2 h1:qHhKW3I70w+04g5KdsdVSHRbFLgt3yY3qTMd4Xa4rC8= +github.com/cosmos/iavl v1.2.2/go.mod h1:GiM43q0pB+uG53mLxLDzimxM9l/5N9UuSY3/D0huuVw= +github.com/cosmos/ibc-go/v10 v10.3.1-0.20250909102629-ed3b125c7b6f h1:I5t5Tuewh6E9icYCtS4aSwyzIEvr2iBods08Hq+GBME= +github.com/cosmos/ibc-go/v10 v10.3.1-0.20250909102629-ed3b125c7b6f/go.mod h1:a74pAPUSJ7NewvmvELU74hUClJhwnmm5MGbEaiTw/kE= +github.com/cosmos/ics23/go v0.11.0 h1:jk5skjT0TqX5e5QJbEnwXIS2yI2vnmLOgpQPeM5RtnU= +github.com/cosmos/ics23/go v0.11.0/go.mod h1:A8OjxPE67hHST4Icw94hOxxFEJMBG031xIGF/JHNIY0= +github.com/cosmos/keyring v1.2.0 h1:8C1lBP9xhImmIabyXW4c3vFjjLiBdGCmfLUfeZlV1Yo= +github.com/cosmos/keyring v1.2.0/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= +github.com/cosmos/ledger-cosmos-go v1.0.0 h1:jNKW89nPf0vR0EkjHG8Zz16h6p3zqwYEOxlHArwgYtw= +github.com/cosmos/ledger-cosmos-go v1.0.0/go.mod h1:mGaw2wDOf+Z6SfRJsMGxU9DIrBa4du0MAiPlpPhLAOE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= +github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE= +github.com/desertbit/timer v1.0.1 h1:yRpYNn5Vaaj6QXecdLMPMJsW81JLiI1eokUft5nBmeo= +github.com/desertbit/timer v1.0.1/go.mod h1:htRrYeY5V/t4iu1xCJ5XsQvp4xve8QulXXctAzxqcwE= +github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= +github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= +github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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/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.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU= +github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.4.1-0.20201022092350-68b0159b7869/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c= +github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +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-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= +github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us= +github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +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= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-getter v1.7.8 h1:mshVHx1Fto0/MydBekWan5zUipGq7jO0novchgMmSiY= +github.com/hashicorp/go-getter v1.7.8/go.mod h1:2c6CboOEb9jG6YvmC9xdD+tyAFsrUaJPedwXDGr0TM4= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= +github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= +github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= +github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= +github.com/huandu/skiplist v1.2.1 h1:dTi93MgjwErA/8idWTzIw4Y1kZsMWx35fmI2c8Rij7w= +github.com/huandu/skiplist v1.2.1/go.mod h1:7v3iFjLcSAzO4fN5B8dvebvo/qsfumiLiDXMrPiHF9w= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/improbable-eng/grpc-web v0.15.0 h1:BN+7z6uNXZ1tQGcNAuaU1YjsLTApzkjt2tzCixLaUPQ= +github.com/improbable-eng/grpc-web v0.15.0/go.mod h1:1sy9HKV4Jt9aEs9JSnkWlRJPuPtwNr0l57L4f878wP8= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U= +github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/linxGnu/grocksdb v1.9.2 h1:O3mzvO0wuzQ9mtlHbDrShixyVjVbmuqTjFrzlf43wZ8= +github.com/linxGnu/grocksdb v1.9.2/go.mod h1:QYiYypR2d4v63Wj1adOOfzglnoII0gLj3PNh4fZkcFA= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= +github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/grpc-proxy v0.0.0-20181017164139-0f1106ef9c76/go.mod h1:x5OoJHDHqxHS801UIuhqGl6QdSAEJvtausosHSdazIo= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a h1:dlRvE5fWabOchtH7znfiFCcOvmIYgOeAS5ifBXBlh9Q= +github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= +github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/runc v1.1.12 h1:BOIssBaW1La0/qbNZHXOOa71dZfZEQOzW7dqQf3phss= +github.com/opencontainers/runc v1.1.12/go.mod h1:S+lQwSfncpBha7XTy/5lBwWgm5+y5Ma/O44Ekby9FK8= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/ory/dockertest v3.3.5+incompatible h1:iLLK6SQwIhcbrG783Dghaaa3WPzGc+4Emza6EbVUUGA= +github.com/ory/dockertest v3.3.5+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.3.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= +github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tendermint/go-amino v0.16.0 h1:GyhmgQKvqF82e2oZeuMSp9JTN0N09emoSZlb2lyGa2E= +github.com/tendermint/go-amino v0.16.0/go.mod h1:TQU0M1i/ImAo+tYpZi73AU3V/dKeCoMC9Sphe2ZwGME= +github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI= +github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zondax/golem v0.27.0 h1:IbBjGIXF3SoGOZHsILJvIM/F/ylwJzMcHAcggiqniPw= +github.com/zondax/golem v0.27.0/go.mod h1:AmorCgJPt00L8xN1VrMBe13PSifoZksnQ1Ge906bu4A= +github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= +github.com/zondax/hid v0.9.2/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= +github.com/zondax/ledger-go v1.0.1 h1:Ks/2tz/dOF+dbRynfZ0dEhcdL1lqw43Sa0zMXHpQ3aQ= +github.com/zondax/ledger-go v1.0.1/go.mod h1:j7IgMY39f30apthJYMd1YsHZRqdyu4KbVmUp0nU78X0= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.4.0-alpha.1 h1:3yrqQzbRRPFPdOMWS/QQIVxVnzSkAZQYeWlZFv1kbj4= +go.etcd.io/bbolt v1.4.0-alpha.1/go.mod h1:S/Z/Nm3iuOnyO1W4XuFfPci51Gj6F1Hv0z8hisyYYOw= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= +golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= +google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/scripts/ibcv2-devnet/mkclient/main.go b/scripts/ibcv2-devnet/mkclient/main.go new file mode 100644 index 00000000..f73dfc1d --- /dev/null +++ b/scripts/ibcv2-devnet/mkclient/main.go @@ -0,0 +1,94 @@ +// mkclient emits the 07-tendermint client_state.json / consensus_state.json +// pair that `cbdcd tx ibc client create` expects, built from a live +// counterparty node. Hand-writing these is error prone, mostly because of the +// ics23 proof specs. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/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" +) + +const ( + defaultTrustingPeriod = 14 * 24 * time.Hour // must be < unbonding period (21d in the devnet) + unbondingTime = 21 * 24 * time.Hour + maxClockDrift = 20 * time.Second +) + +func main() { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: mkclient [trusting-period-seconds]") + os.Exit(1) + } + rpcURL, outDir := os.Args[1], os.Args[2] + trustingPeriod := defaultTrustingPeriod + if len(os.Args) > 3 { + secs, err := strconv.Atoi(os.Args[3]) + must(err) + trustingPeriod = time.Duration(secs) * time.Second + } + ctx := context.Background() + + c, err := rpchttp.New(rpcURL, "/websocket") + must(err) + + status, err := c.Status(ctx) + must(err) + chainID := status.NodeInfo.Network + height := status.SyncInfo.LatestBlockHeight + + commit, err := c.Commit(ctx, &height) + must(err) + header := commit.Header + + revision := clienttypes.ParseChainID(chainID) + + clientState := ibctm.NewClientState( + chainID, + ibctm.DefaultTrustLevel, + trustingPeriod, + unbondingTime, + maxClockDrift, + clienttypes.NewHeight(revision, uint64(height)), + commitmenttypes.GetSDKSpecs(), + []string{"upgrade", "upgradedIBCState"}, + ) + consensusState := ibctm.NewConsensusState( + header.Time, + commitmenttypes.NewMerkleRoot(header.AppHash), + header.NextValidatorsHash, + ) + + registry := codectypes.NewInterfaceRegistry() + ibctm.RegisterInterfaces(registry) + cdc := codec.NewProtoCodec(registry) + + csJSON, err := cdc.MarshalInterfaceJSON(clientState) + must(err) + consJSON, err := cdc.MarshalInterfaceJSON(consensusState) + must(err) + + must(os.MkdirAll(outDir, 0o755)) + must(os.WriteFile(filepath.Join(outDir, "client_state.json"), csJSON, 0o644)) + must(os.WriteFile(filepath.Join(outDir, "consensus_state.json"), consJSON, 0o644)) + + fmt.Printf("%s revision=%d height=%d -> %s\n", chainID, revision, height, outDir) +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/scripts/ibcv2-devnet/path.txt b/scripts/ibcv2-devnet/path.txt new file mode 100644 index 00000000..c5033dba --- /dev/null +++ b/scripts/ibcv2-devnet/path.txt @@ -0,0 +1 @@ +07-tendermint-0 07-tendermint-0 diff --git a/scripts/ibcv2-devnet/simd b/scripts/ibcv2-devnet/simd new file mode 100755 index 00000000..df327575 --- /dev/null +++ b/scripts/ibcv2-devnet/simd @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/data -v "/tmp/claude-1000/-home-alvaro-laptop-work-cbdc/6bf21cc8-f549-4804-97d9-5330a30c0764/scratchpad/devnet/chain-c:/data" ${SIMD_IMAGE:-ghcr.io/cosmos/ibc-go-simd:main} "$@" --home /data --node http://127.0.0.1:26857 diff --git a/scripts/ibcv2-devnet/transfer.sh b/scripts/ibcv2-devnet/transfer.sh new file mode 100755 index 00000000..77309610 --- /dev/null +++ b/scripts/ibcv2-devnet/transfer.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Send an IBC v2 transfer between any two devnet chains and drive the relayer +# to completion. +# +# ./transfer.sh a b 1000000000000000axrp +# ./transfer.sh b c 1000000000000000ibc/ +set -euo pipefail + +DEVNET="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${REPO:-$(cd "$DEVNET/../.." && pwd)}" +CBDCD="$REPO/bin/cbdcd" +SIMD_IMAGE="${SIMD_IMAGE:-ghcr.io/cosmos/ibc-go-simd:main}" + +# shellcheck disable=SC1091 +source "$DEVNET/paths.env" + +SRC="${1:?usage: transfer.sh }" +DST="${2:?usage: transfer.sh }" +COIN="${3:?usage: transfer.sh }" + +client_var="$(echo "${SRC}_TO_${DST}" | tr '[:lower:]' '[:upper:]')" +CLIENT="${!client_var:?no path from $SRC to $DST}" + +case "$SRC" in +a) SRC_ID=$A_ID SRC_RPC=http://127.0.0.1:26657 SRC_HOME=$DEVNET/chain-a ;; +b) SRC_ID=$B_ID SRC_RPC=http://127.0.0.1:26757 SRC_HOME=$DEVNET/chain-b ;; +c) SRC_ID=$C_ID SRC_RPC=http://127.0.0.1:26857 SRC_HOME=simd ;; +*) echo "bad source $SRC" >&2; exit 1 ;; +esac +case "$DST" in +a) RECEIVER=$A_ALICE ;; +b) RECEIVER=$B_ALICE ;; +c) RECEIVER=$C_ALICE ;; +*) echo "bad destination $DST" >&2; exit 1 ;; +esac + +# IBC v2 timeouts are unix *seconds*, capped at now + 24h (MaxTimeoutDelta). +# The transfer CLI's relative default is nanoseconds, which the v2 path reads +# as seconds and rejects, so always pass an absolute timeout. +TIMEOUT=$(( $(date +%s) + 3600 )) + +if [ "$SRC_HOME" = "simd" ]; then + TX=$(docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/data \ + -v "$DEVNET/chain-c:/data" "$SIMD_IMAGE" \ + tx ibc-transfer transfer transfer "$CLIENT" "$RECEIVER" "$COIN" \ + --absolute-timeouts --packet-timeout-height 0-0 --packet-timeout-timestamp "$TIMEOUT" \ + --from alice --home /data --node "$SRC_RPC" --chain-id "$SRC_ID" \ + --keyring-backend test --gas 800000 --fees 0stake -y -o json 2>/dev/null | tail -1 | jq -r .txhash) +else + TX=$($CBDCD tx ibc-transfer transfer transfer "$CLIENT" "$RECEIVER" "$COIN" \ + --absolute-timeouts --packet-timeout-height 0-0 --packet-timeout-timestamp "$TIMEOUT" \ + --from alice --home "$SRC_HOME" --chain-id "$SRC_ID" --node "$SRC_RPC" \ + --keyring-backend test --gas 800000 --fees 0axrp -y -o json | jq -r .txhash) +fi + +echo "$SRC -> $DST client=$CLIENT tx=$TX" +sleep 6 + +grpcurl -plaintext -d "{\"tx_hash\":\"$TX\",\"chain_id\":\"$SRC_ID\"}" "$RELAYER_API" \ + skip.relayer.RelayerApiService/Relay >/dev/null + +for _ in $(seq 1 30); do + sleep 5 + STATE=$(grpcurl -plaintext -d "{\"tx_hash\":\"$TX\",\"chain_id\":\"$SRC_ID\"}" "$RELAYER_API" \ + skip.relayer.RelayerApiService/Status 2>/dev/null | jq -r '.packetStatuses[0].state') + [ "$STATE" = "TRANSFER_STATE_COMPLETE" ] && break + [ "$STATE" = "TRANSFER_STATE_FAILED" ] && { echo "FAILED"; exit 1; } +done +echo " $STATE" + +# COMPLETE means the packet lifecycle finished, NOT that value moved: a rejected +# receive also ends COMPLETE, with a refund instead of a delivery. +grpcurl -plaintext -d "{\"tx_hash\":\"$TX\",\"chain_id\":\"$SRC_ID\"}" "$RELAYER_API" \ + skip.relayer.RelayerApiService/Status 2>/dev/null | + jq -c '.packetStatuses[0]|{state,seq:.sequenceNumber,recv:.recvTx.chainId,ack:.ackTx.chainId,timeout:.timeoutTx.chainId}' diff --git a/scripts/ibcv2-devnet/two-chains.sh b/scripts/ibcv2-devnet/two-chains.sh new file mode 100755 index 00000000..1566ce18 --- /dev/null +++ b/scripts/ibcv2-devnet/two-chains.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Bring up two independent single-node cbdc chains for IBC v2 work. +# Derived from cbdc-node/local-node.sh, parameterised by chain id + port offset. +set -euo pipefail + +REPO="${REPO:-/home/alvaro-laptop/work/cbdc/cbdc-node}" +DEVNET="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CBDCD="$REPO/bin/cbdcd" + +KEYRING="test" +KEYALGO="eth_secp256k1" +# A tendermint light client's trusting period must be shorter than the unbonding +# period. The 60s default in local-node.sh expires a client immediately. +UNBONDING_TIME="1814400s" + +mkdir -p "$DEVNET/logs" + +setup_chain() { + local NAME="$1" CHAINID="$2" OFFSET="$3" + local HOMEDIR="$DEVNET/$NAME" + local CONFIG="$HOMEDIR/config/config.toml" + local APP_TOML="$HOMEDIR/config/app.toml" + local GENESIS="$HOMEDIR/config/genesis.json" + local TMP="$HOMEDIR/config/tmp_genesis.json" + + rm -rf "$HOMEDIR" + + $CBDCD --home "$HOMEDIR" config set client chain-id "$CHAINID" --chain-id "$CHAINID" + $CBDCD --home "$HOMEDIR" config set client keyring-backend "$KEYRING" + + $CBDCD --home "$HOMEDIR" keys add alice --keyring-backend "$KEYRING" --algo "$KEYALGO" >/dev/null 2>&1 + $CBDCD --home "$HOMEDIR" keys add relayer --keyring-backend "$KEYRING" --algo "$KEYALGO" >/dev/null 2>&1 + + $CBDCD --home "$HOMEDIR" init "$NAME" -o --chain-id "$CHAINID" >/dev/null 2>&1 + + local ALICE RELAYER + ALICE=$($CBDCD --home "$HOMEDIR" keys show alice -a --keyring-backend "$KEYRING") + RELAYER=$($CBDCD --home "$HOMEDIR" keys show relayer -a --keyring-backend "$KEYRING") + + jq '.consensus.params["block"]["max_gas"]="10500000"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["crisis"]["constant_fee"]["denom"]="axrp"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["evm"]["params"]["evm_denom"]="axrp"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["gov"]["params"]["min_deposit"][0]["denom"]="axrp"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["gov"]["params"]["min_deposit"][0]["amount"]="1"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["gov"]["params"]["voting_period"]="10s"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["gov"]["params"]["expedited_voting_period"]="5s"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["staking"]["params"]["bond_denom"]="apoa"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["staking"]["params"]["unbonding_time"]="'"$UNBONDING_TIME"'"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["feemarket"]["params"]["base_fee"]="0"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["feemarket"]["params"]["no_base_fee"]=true' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["feemarket"]["params"]["min_gas_price"]="0.000000000000000000"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state.bank.denom_metadata=[{"description":"XRP is the gas token","denom_units":[{"denom":"axrp"},{"denom":"xrp","exponent":18}],"base":"axrp","display":"xrp","name":"XRP","symbol":"XRP"}]' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state.erc20.native_precompiles=["0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state.erc20.token_pairs=[{contract_owner:1,erc20_address:"0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",denom:"axrp",enabled:true,"owner_address":"'"$ALICE"'"}]' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["slashing"]["params"]["slash_fraction_double_sign"]="0"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["slashing"]["params"]["slash_fraction_downtime"]="0"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + jq '.app_state["cbdc"]["params"]["owner"]="'"$ALICE"'"' "$GENESIS" >"$TMP" && mv "$TMP" "$GENESIS" + + $CBDCD --home "$HOMEDIR" genesis add-genesis-account "$ALICE" 1000000apoa,1000000000000000000000000000axrp --keyring-backend "$KEYRING" + $CBDCD --home "$HOMEDIR" genesis add-genesis-account "$RELAYER" 1000000000000000000000000000axrp --keyring-backend "$KEYRING" + + $CBDCD --home "$HOMEDIR" genesis gentx alice 1000000apoa --fees 0axrp --gas 1000000 --keyring-backend "$KEYRING" --chain-id "$CHAINID" >/dev/null 2>&1 + $CBDCD --home "$HOMEDIR" genesis collect-gentxs >/dev/null 2>&1 + $CBDCD --home "$HOMEDIR" genesis validate >/dev/null + + # ports + sed -i "s|laddr = \"tcp://127.0.0.1:26657\"|laddr = \"tcp://0.0.0.0:$((26657 + OFFSET))\"|" "$CONFIG" + sed -i "s|laddr = \"tcp://0.0.0.0:26656\"|laddr = \"tcp://0.0.0.0:$((26656 + OFFSET))\"|" "$CONFIG" + sed -i "s|pprof_laddr = \"localhost:6060\"|pprof_laddr = \"localhost:$((6060 + OFFSET))\"|" "$CONFIG" + sed -i "s|prometheus_listen_addr = \":26660\"|prometheus_listen_addr = \":$((26660 + OFFSET))\"|" "$CONFIG" + sed -i "s|address = \"tcp://localhost:1317\"|address = \"tcp://0.0.0.0:$((1317 + OFFSET))\"|" "$APP_TOML" + sed -i "s|address = \"localhost:9090\"|address = \"0.0.0.0:$((9090 + OFFSET))\"|" "$APP_TOML" + sed -i "s|address = \"127.0.0.1:8545\"|address = \"127.0.0.1:$((8545 + OFFSET))\"|" "$APP_TOML" + sed -i "s|ws-address = \"127.0.0.1:8546\"|ws-address = \"127.0.0.1:$((8546 + OFFSET))\"|" "$APP_TOML" + sed -i "s|geth-metrics-address = \"127.0.0.1:8100\"|geth-metrics-address = \"127.0.0.1:$((8100 + OFFSET))\"|" "$APP_TOML" + sed -i "s|metrics-address = \"127.0.0.1:6065\"|metrics-address = \"127.0.0.1:$((6065 + OFFSET))\"|" "$APP_TOML" + + # enable APIs, keep memiavl off + sed -i 's/prometheus = false/prometheus = true/' "$CONFIG" + sed -i 's/enabled = false/enabled = true/g' "$APP_TOML" + sed -i 's/enable = false/enable = true/g' "$APP_TOML" + grep -q -F '[memiavl]' "$APP_TOML" && sed -i '/\[memiavl\]/,/^\[/ s/enable = true/enable = false/' "$APP_TOML" + + # tx indexing is required: the relayer and proof API look packets up by tx hash + sed -i 's/^indexer = .*/indexer = "kv"/' "$CONFIG" + + cat >"$DEVNET/$NAME.env" <"$DEVNET/logs/$NAME.log" 2>&1 & + echo $! >"$DEVNET/logs/$NAME.pid" + echo " started $NAME pid=$(cat "$DEVNET/logs/$NAME.pid")" +} + +case "${1:-all}" in +init) + echo "initialising chains:" + setup_chain chain-a cbdc_1449999-1 0 + setup_chain chain-b cbdc_1449998-1 100 + ;; +start) + echo "starting chains:" + start_chain chain-a + start_chain chain-b + ;; +stop) + for p in "$DEVNET"/logs/*.pid; do + [ -f "$p" ] && kill "$(cat "$p")" 2>/dev/null && echo " stopped $(basename "$p" .pid)" + rm -f "$p" + done + ;; +all) + "$0" stop || true + "$0" init + "$0" start + ;; +esac diff --git a/scripts/ibcv2-devnet/up.sh b/scripts/ibcv2-devnet/up.sh new file mode 100755 index 00000000..7b32293e --- /dev/null +++ b/scripts/ibcv2-devnet/up.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# Bring up the full IBC v2 devnet from scratch: +# chain-a, chain-b cbdc-node (EVM, eth_secp256k1 accounts) +# chain-c ibc-go simapp (plain SDK, cross-implementation counterparty) +# postgres + proof-api + cosmos/ibc-relayer, with all three paths wired. +# +# Required: +# RELAYER_BIN path to the cosmos/ibc-relayer binary +# PROOF_API_IMAGE docker image of solidity-ibc-eureka programs/proof-api +# Optional: +# REPO cbdc-node checkout (default: ../../ from this script) +set -euo pipefail + +DEVNET="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${REPO:-$(cd "$DEVNET/../.." && pwd)}" +CBDCD="$REPO/bin/cbdcd" +RELAYER_BIN="${RELAYER_BIN:-$DEVNET/bin/relayer}" +PROOF_API_IMAGE="${PROOF_API_IMAGE:-proof-api:local}" +SIMD_IMAGE="${SIMD_IMAGE:-ghcr.io/cosmos/ibc-go-simd:main}" +MKCLIENT="${MKCLIENT:-$DEVNET/bin/mkclient}" + +RELAYER_API="127.0.0.1:9001" # NOT 9000: proof-api also binds 9000 (undocumented) +PROOF_API_PORT=3000 +PG_PORT=42500 + +log() { printf '\n\033[1m== %s\033[0m\n' "$*"; } + +require() { + [ -x "$CBDCD" ] || { echo "missing $CBDCD — run 'make build' in $REPO" >&2; exit 1; } + [ -x "$RELAYER_BIN" ] || { echo "set RELAYER_BIN to the cosmos/ibc-relayer binary" >&2; exit 1; } + [ -x "$MKCLIENT" ] || { echo "missing $MKCLIENT — build it from ./mkclient" >&2; exit 1; } + docker image inspect "$PROOF_API_IMAGE" >/dev/null 2>&1 || { echo "missing image $PROOF_API_IMAGE" >&2; exit 1; } +} + +down() { + log "tearing down" + "$DEVNET/two-chains.sh" stop >/dev/null 2>&1 || true + SIMD_IMAGE="$SIMD_IMAGE" "$DEVNET/chain-c.sh" stop >/dev/null 2>&1 || true + pkill -x relayer 2>/dev/null || true + { true; } && docker rm -f proof-api ibcv2-postgres ibc-relayer-postgres-1 >/dev/null 2>&1 || true + sleep 2 +} + +wait_height() { # wait_height + for _ in $(seq 1 60); do + h=$(curl -s --max-time 2 "$1/status" 2>/dev/null | jq -r '.result.sync_info.latest_block_height // 0') + [ "${h:-0}" -ge 2 ] 2>/dev/null && { echo " $2 at height $h"; return 0; } + sleep 2 + done + echo " $2 never produced blocks" >&2; return 1 +} + +txa() { $CBDCD tx "$@" --keyring-backend test --gas 500000 --fees 0axrp -y -o json | jq -r .txhash; } +simd() { docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/data \ + -v "$DEVNET/chain-c:/data" "$SIMD_IMAGE" "$@" --home /data --node http://127.0.0.1:26857; } + +# create_path +# Returns " " and registers counterparties both ways. +create_path() { + local ha="$1" ca="$2" ra="$3" hb="$4" cb="$5" rb="$6" + "$MKCLIENT" "$rb" "$DEVNET/cs/$cb" >/dev/null + "$MKCLIENT" "$ra" "$DEVNET/cs/$ca" >/dev/null + + if [ "$ha" = "simd" ]; then :; else + txa ibc client create "$DEVNET/cs/$cb/client_state.json" "$DEVNET/cs/$cb/consensus_state.json" \ + --from alice --home "$ha" --chain-id "$ca" --node "$ra" >/dev/null + fi + if [ "$hb" = "simd" ]; then + cp "$DEVNET/cs/$ca"/*.json "$DEVNET/chain-c/" + simd tx ibc client create /data/client_state.json /data/consensus_state.json \ + --from alice --chain-id "$cb" --keyring-backend test --gas 500000 --fees 0stake -y >/dev/null 2>&1 + else + txa ibc client create "$DEVNET/cs/$ca/client_state.json" "$DEVNET/cs/$ca/consensus_state.json" \ + --from alice --home "$hb" --chain-id "$cb" --node "$rb" >/dev/null + fi + sleep 6 + + local ida idb + ida=$($CBDCD q ibc client states --node "$ra" -o json | jq -r '.client_states[-1].client_id') + if [ "$hb" = "simd" ]; then + idb=$(simd q ibc client states -o json 2>/dev/null | jq -r '.client_states[-1].client_id') + else + idb=$($CBDCD q ibc client states --node "$rb" -o json | jq -r '.client_states[-1].client_id') + fi + + # "aWJj" is base64("ibc"); the trailing empty element matches ibc-go's + # MerklePath = NewMerklePath([]byte("ibc"), []byte("")). A one-element + # prefix fails silently at send time and is unrecoverable. + txa ibc client add-counterparty "$ida" "$idb" "aWJj" "" --from alice --home "$ha" --chain-id "$ca" --node "$ra" >/dev/null + if [ "$hb" = "simd" ]; then + simd tx ibc client add-counterparty "$idb" "$ida" "aWJj" "" --from alice --chain-id "$cb" --keyring-backend test --gas 500000 --fees 0stake -y >/dev/null 2>&1 + else + txa ibc client add-counterparty "$idb" "$ida" "aWJj" "" --from alice --home "$hb" --chain-id "$cb" --node "$rb" >/dev/null + fi + sleep 6 + echo "$ida $idb" +} + +require +down +mkdir -p "$DEVNET/cs" "$DEVNET/logs" + +log "starting chain-a and chain-b (cbdc-node)" +"$DEVNET/two-chains.sh" init +"$DEVNET/two-chains.sh" start +log "starting chain-c (ibc-go simapp)" +SIMD_IMAGE="$SIMD_IMAGE" "$DEVNET/chain-c.sh" init +SIMD_IMAGE="$SIMD_IMAGE" "$DEVNET/chain-c.sh" start + +log "waiting for blocks" +wait_height http://127.0.0.1:26657 chain-a +wait_height http://127.0.0.1:26757 chain-b +wait_height http://127.0.0.1:26857 chain-c + +# shellcheck disable=SC1091 +source "$DEVNET/chain-a.env"; A_ID=$CHAIN_ID A_RPC=$RPC A_HOME=$HOME_DIR A_ALICE=$ALICE +# shellcheck disable=SC1091 +source "$DEVNET/chain-b.env"; B_ID=$CHAIN_ID B_RPC=$RPC B_HOME=$HOME_DIR B_ALICE=$ALICE +# shellcheck disable=SC1091 +source "$DEVNET/chain-c.env"; C_ID=$CHAIN_ID C_ALICE=$ALICE + +log "creating relayer keys" +# The relayer signs with plain cosmos secp256k1. cbdc-node's ante uses the +# stock SDK decorators, so such accounts are accepted even though the chain's +# own keys are eth_secp256k1. +for h in "$A_HOME" "$B_HOME"; do + $CBDCD keys add rly --algo secp256k1 --keyring-backend test --home "$h" >/dev/null 2>&1 +done +RLY_A=$($CBDCD keys show rly -a --keyring-backend test --home "$A_HOME") +RLY_B=$($CBDCD keys show rly -a --keyring-backend test --home "$B_HOME") +KEY_A=$( printf 'y\ny\n' | $CBDCD keys export rly --unarmored-hex --unsafe --keyring-backend test --home "$A_HOME" 2>/dev/null | tail -1) +KEY_B=$( printf 'y\ny\n' | $CBDCD keys export rly --unarmored-hex --unsafe --keyring-backend test --home "$B_HOME" 2>/dev/null | tail -1) +RLY_C=$(docker run --rm -i --user "$(id -u):$(id -g)" -e HOME=/data -v "$DEVNET/chain-c:/data" "$SIMD_IMAGE" keys show relayer -a --keyring-backend test --home /data | tr -d '\r') +KEY_C=$( printf 'y\ny\n' | docker run --rm -i --user "$(id -u):$(id -g)" -e HOME=/data -v "$DEVNET/chain-c:/data" "$SIMD_IMAGE" keys export relayer --unarmored-hex --unsafe --keyring-backend test --home /data 2>/dev/null | tail -1 | tr -d '\r') + +$CBDCD tx bank send alice "$RLY_A" 1000000000000000000000axrp --from alice --home "$A_HOME" --chain-id "$A_ID" --node "$A_RPC" --keyring-backend test --gas 300000 --fees 0axrp -y >/dev/null +$CBDCD tx bank send alice "$RLY_B" 1000000000000000000000axrp --from alice --home "$B_HOME" --chain-id "$B_ID" --node "$B_RPC" --keyring-backend test --gas 300000 --fees 0axrp -y >/dev/null +sleep 6 + +log "creating IBC v2 paths" +read -r A_TO_B B_TO_A <<<"$(create_path "$A_HOME" "$A_ID" "$A_RPC" "$B_HOME" "$B_ID" "$B_RPC")" +echo " a<->b $A_TO_B / $B_TO_A" +read -r A_TO_C C_TO_A <<<"$(create_path "$A_HOME" "$A_ID" "$A_RPC" simd "$C_ID" http://127.0.0.1:26857)" +echo " a<->c $A_TO_C / $C_TO_A" +read -r B_TO_C C_TO_B <<<"$(create_path "$B_HOME" "$B_ID" "$B_RPC" simd "$C_ID" http://127.0.0.1:26857)" +echo " b<->c $B_TO_C / $C_TO_B" + +log "writing relayer + proof-api config" +cat >"$DEVNET/ibcv2keys.json" <"$DEVNET/relayer-config.yml" + +python3 - "$DEVNET" "$A_ID" "$B_ID" "$C_ID" "$RLY_A" "$RLY_B" "$RLY_C" <<'PY' +import json,sys +d,a,b,c,ra,rb,rc = sys.argv[1:8] +rpc={a:"http://127.0.0.1:26657", b:"http://127.0.0.1:26757", c:"http://127.0.0.1:26857"} +signer={a:ra, b:rb, c:rc} +mods=[{"name":"cosmos_to_cosmos","src_chain":s,"dst_chain":t, + "config":{"src_rpc_url":rpc[s],"target_rpc_url":rpc[t],"signer_address":signer[t]}} + for s in (a,b,c) for t in (a,b,c) if s!=t] +json.dump({"server":{"address":"0.0.0.0","port":3000,"grpc_web_port":8081}, + "observability":{"level":"info","use_otel":False,"service_name":"ibc-proof-api","otel_endpoint":None}, + "modules":mods}, open(d+"/proof-api.json","w"), indent=2) +PY + +log "starting postgres, proof-api, relayer" +docker run -d --name ibcv2-postgres -e POSTGRES_USER=relayer -e POSTGRES_PASSWORD=relayer \ + -e POSTGRES_DB=relayer -p $PG_PORT:5432 postgres:18 >/dev/null +for _ in $(seq 1 30); do docker exec ibcv2-postgres pg_isready -U relayer -d relayer >/dev/null 2>&1 && break; sleep 1; done +"$RELAYER_BIN" migrate --config "$DEVNET/relayer-config.yml" +docker run -d --name proof-api --network host -v "$DEVNET/proof-api.json:/config.json:ro" \ + "$PROOF_API_IMAGE" start --config /config.json >/dev/null +# TZ=UTC is required: the relayer stores packet deadlines as local wall-clock in +# a `timestamp without time zone` column, so any non-UTC host skews every timeout. +TZ=UTC nohup "$RELAYER_BIN" --config "$DEVNET/relayer-config.yml" >"$DEVNET/logs/relayer.log" 2>&1 & +sleep 10 + +cat >"$DEVNET/paths.env" < Date: Thu, 30 Jul 2026 08:38:06 +0200 Subject: [PATCH 08/61] chore(deps): bump xrplevm/evm to v0.6.1-xrplevm.1 Tracks upstream cosmos/evm v0.6.1, which upstream describes as containing "important security fixes ... we recommend all chains upgrade to this patch release as soon as possible using a coordinated upgrade. This release is state breaking." Its changelog includes chore(erc20/v2): align ack validation with ibc-go, in the middleware the v2 transfer stack depends on. Verified against the module proxy before taking it: v0.6.1-xrplevm.1 is a true drop-in. Go 1.23.8, cosmos-sdk v0.53.6, CometBFT v0.38.21, cosmossdk.io/store v1.1.2 and the same ibc-go pseudo-version this chain already builds against -- every pin identical to v0.6.0-xrplevm.6. Do NOT take v1.0.0-rc*, which the fork line now also carries. Despite the version number it is a downgrade on every axis that matters: cosmos-sdk v0.53.0, CometBFT v0.38.17 and ibc-go v10.0.0-beta. It is a trap for anyone scanning the tag list. State breaking costs nothing here because the chain is not deployed. After genesis this becomes a coordinated upgrade, which is the argument for taking it now. go mod tidy promotes cosmos/ics23/go to a direct dependency (the ICS-23 proof constructor in x/qbftclient/prover/cosmos uses it) and drops indirects that are no longer reachable. Verified: go build ./... clean; x/qbftclient, x/poa and x/cbdc unit suites green; tests/integration green. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 7 ++----- go.sum | 13 ++----------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index ecb9a087..5da91a6e 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 f4a5e076..adc87414 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= From 498331f9ae132a02925e896ef3aae1134bc78206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 09:01:33 +0200 Subject: [PATCH 09/61] fix(qbftclient): read the Besu chain id from the node in live tests Both live suites hardcoded chainId 1338 -- the spoke the tests were first written against. Every Scenario A spoke is the same chain with a different chainId injected by the toolkit, so the hardcoded value silently restricted the tests to one country: against any other spoke signing fails with "Wrong chainId", and in the integration test the ClientState would additionally have described the wrong counterparty. Both now read eth_chainId from the node under test. Verified against a fresh Besu 25.8.0 QBFT chain built to the Scenario A genesis shape with chainId 1337 (brazil): all seven prover/besu live checks pass, and TestLive_InboundLegEndToEnd completes -- a real packet commitment in real Besu storage, real headers accepted by cbdc-node's real ClientKeeper, and a real Merkle-Patricia proof accepted by VerifyMembership. This matters for the N-country goal specifically: the corridor is cbdc-node connected to every Scenario A spoke, so anything keyed to a single chainId cannot be part of proving it. Co-Authored-By: Claude Opus 5 (1M context) --- tests/integration/qbft_live_test.go | 20 ++++++++++++++--- x/qbftclient/prover/besu/live_storage_test.go | 22 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/integration/qbft_live_test.go b/tests/integration/qbft_live_test.go index 05115175..c36e6382 100644 --- a/tests/integration/qbft_live_test.go +++ b/tests/integration/qbft_live_test.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "math/big" "os" + "strings" "testing" "time" @@ -33,7 +34,20 @@ import ( // build the real contracts. const storageWriterCode = "0x6008600c60003960086000f36020356000355500" -var liveChainID = big.NewInt(1338) +// liveChainID reads the chain id from the node instead of assuming one. Every +// Scenario A spoke is the same chain with a different chainId injected, so a +// hardcoded value restricts this test to whichever spoke it was written against: +// signing fails with "Wrong chainId", and the ClientState below would describe +// the wrong counterparty. +func liveChainID(t *testing.T, ctx context.Context, c *rpc.Client) *big.Int { + t.Helper() + + var hex string + require.NoError(t, c.CallContext(ctx, &hex, "eth_chainId")) + id, ok := new(big.Int).SetString(strings.TrimPrefix(hex, "0x"), 16) + require.Truef(t, ok, "eth_chainId: cannot parse %q", hex) + return id +} // TestLive_InboundLegEndToEnd is the closest thing to the pilot that can be run // without the counterparty's contracts: a real packet commitment written into a @@ -115,7 +129,7 @@ func TestLive_InboundLegEndToEnd(t *testing.T) { // Besu's wall clock are unrelated, and what is under test here is byte // agreement, not time policy — which VerifyHeader's own unit tests cover. clientStateBz, err := cdc.Marshal(&qbfttypes.ClientState{ - ChainId: uint64(liveChainID.Int64()), + ChainId: uint64(liveChainID(t, ctx, rpcClient).Int64()), TrustingPeriod: uint64((100 * 365 * 24 * time.Hour) / time.Second), MaxClockDrift: uint64((100 * 365 * 24 * time.Hour) / time.Second), LatestHeight: trustedHeight, @@ -185,7 +199,7 @@ func liveSend(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.Priva GasPrice: big.NewInt(0), Data: data, }) - signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(liveChainID), key) + signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(liveChainID(t, ctx, c)), key) require.NoError(t, err) raw, err := signed.MarshalBinary() require.NoError(t, err) diff --git a/x/qbftclient/prover/besu/live_storage_test.go b/x/qbftclient/prover/besu/live_storage_test.go index 05f3966d..06f50a8f 100644 --- a/x/qbftclient/prover/besu/live_storage_test.go +++ b/x/qbftclient/prover/besu/live_storage_test.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "math/big" "os" + "strings" "testing" "time" @@ -31,8 +32,23 @@ import ( // runtime: 6020356000355500 value=calldata[32], key=calldata[0], SSTORE, STOP const storageWriterCode = "0x6008600c60003960086000f36020356000355500" -// besuChainID matches the genesis this test expects; zeroBaseFee lets gas price be 0. -var besuChainID = big.NewInt(1338) +// chainIDOf reads the chain id from the node rather than assuming one. Every +// Scenario A spoke is the same chain with a different chainId injected, so a +// hardcoded value silently restricts this test to whichever spoke it was written +// against -- it fails with "Wrong chainId" on every other one. +func chainIDOf(t *testing.T, ctx context.Context, c *rpc.Client) *big.Int { + t.Helper() + + var hex string + if err := c.CallContext(ctx, &hex, "eth_chainId"); err != nil { + t.Fatalf("eth_chainId: %v", err) + } + id, ok := new(big.Int).SetString(strings.TrimPrefix(hex, "0x"), 16) + if !ok { + t.Fatalf("eth_chainId: cannot parse %q", hex) + } + return id +} // liveRPC returns a raw RPC client, or skips. QBFT_BESU_KEY must hold the hex // private key of a genesis-funded account — a devnet key, never a real one. @@ -142,7 +158,7 @@ func send(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKe Data: data, }) - signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(besuChainID), key) + signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(chainIDOf(t, ctx, c)), key) if err != nil { t.Fatalf("sign tx: %v", err) } From 0ac329c526ed554aa4c1fa5c866af5ef092320b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 09:20:41 +0200 Subject: [PATCH 10/61] fix(qbftclient): lengthen ClientType so packets can be received ClientType was "qbft", so ibc-go generated the client id "qbft-0". IBC v2 carries client ids in the position v1 used for channel ids, and a packet's source and destination ids are validated with host.ChannelIdentifierValidator, which requires 8-64 characters. Six is not enough. MsgCreateClient does not apply that validator, so the failure is silent in exactly the worst way: clients are created successfully, the client reports Active, membership proofs verify, and then every single MsgRecvPacket fails with invalid destination ID: identifier qbft-0 has invalid length: 6, must be between 8-64 characters There is no in-place correction. The client id is baked into the counterparty registration on both sides -- and RegisterCounterparty deletes the client creator, so it cannot be re-registered -- and under DEC-5 it also determines the escrow address and the voucher denom. Recovery means a new client pair and a drain. ClientType is now "qbftclient", giving "qbftclient-0". Found on a local two-chain rig: a real Besu 25.8.0 QBFT chain carrying the deployed solidity-ibc-eureka stack, and cbdc-node. Every earlier test passed because none of them drove a real MsgRecvPacket through the msg server -- the live test called ClientKeeper.VerifyMembership directly, which does not validate identifiers. Guarded now by asserting the generated id against ChannelIdentifierValidator, so the gap cannot reopen. Co-Authored-By: Claude Opus 5 (1M context) --- tests/integration/qbftclient_test.go | 13 ++++++++++++- x/qbftclient/types/client_message.go | 19 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/integration/qbftclient_test.go b/tests/integration/qbftclient_test.go index 86d40d50..ef4581e8 100644 --- a/tests/integration/qbftclient_test.go +++ b/tests/integration/qbftclient_test.go @@ -8,6 +8,7 @@ import ( clienttypes "github.com/cosmos/ibc-go/v10/modules/core/02-client/types" commitmenttypesv2 "github.com/cosmos/ibc-go/v10/modules/core/23-commitment/types/v2" + host "github.com/cosmos/ibc-go/v10/modules/core/24-host" "github.com/cosmos/ibc-go/v10/modules/core/exported" "github.com/peersyst/cbdc-node/app" @@ -74,7 +75,17 @@ func TestQBFTClient_Lifecycle(t *testing.T) { clientID, err := k.CreateClient(ctx, qbfttypes.ClientType, clientStateBz, consensusStateBz) require.NoError(t, err, "creating a QBFT client must succeed") - require.Equal(t, "qbft-0", clientID, "client id is -") + require.Equal(t, "qbftclient-0", clientID, "client id is -") + + // Regression guard. IBC v2 carries client ids where v1 carried channel ids, so + // a packet's source and destination ids are validated with + // ChannelIdentifierValidator -- minimum eight characters. CreateClient does not + // apply it, so a too-short ClientType yields clients that are created happily + // and then reject every MsgRecvPacket with "invalid destination ID". The id is + // baked into the counterparty registration on both sides and, under DEC-5, into + // the escrow address and voucher denom, so it cannot be corrected in place. + require.NoError(t, host.ChannelIdentifierValidator(clientID), + "a client id must be usable as a v2 packet destination, or no packet can ever be received") // The route registered in app.go must resolve, and the client must be usable. require.Equal(t, exported.Active, k.GetClientStatus(ctx, clientID)) diff --git a/x/qbftclient/types/client_message.go b/x/qbftclient/types/client_message.go index e5817392..59222b4c 100644 --- a/x/qbftclient/types/client_message.go +++ b/x/qbftclient/types/client_message.go @@ -9,7 +9,24 @@ import ( // ClientType is the ibc-go client type identifier for this light client. Client // ids are formed as "-", e.g. qbft-0. -const ClientType = "qbft" +// ClientType is the prefix of every client id this module creates: ibc-go +// generates "-". +// +// It must be at least six characters. IBC v2 carries client ids in the position +// v1 used for channel ids, so a packet's source and destination ids are checked +// with host.ChannelIdentifierValidator, which requires 8-64 characters. The +// original value here was "qbft", producing "qbft-0" -- six characters. Clients +// were created successfully, because MsgCreateClient does not apply that +// validator, and then every MsgRecvPacket failed with +// +// invalid destination ID: identifier qbft-0 has invalid length: 6, +// must be between 8-64 characters +// +// which is unrecoverable in place: the client id is baked into the counterparty +// registration on both sides and, under DEC-5, into the escrow address and +// voucher denom. Caught on the bench rig; it would otherwise have surfaced at the +// pilot's first receive. +const ClientType = "qbftclient" // ClientType implements the ibc-go exported.ClientState interface. func (cs *ClientState) ClientType() string { return ClientType } From c78bd09c289329f98a02aa898cce751d8da73492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 09:25:44 +0200 Subject: [PATCH 11/61] feat(scripts): Besu/QBFT devnet and a Solidity->Go packet converter Two pieces of tooling the repo lacked, both needed to drive a real corridor. scripts/besu-devnet/up.sh brings up a single-validator Besu 25.8.0 QBFT chain matching the Scenario A spoke genesis. The live tests have always said "bring one up with the cbweb3 genesis shape" without a way to do it. Genesis constants come from renderQBFTConfig in cbweb3-platform's toolkit, which is the authoritative source -- note that provisioning/templates/.../examples/qbftConfigFile.json is materially stale against it (London only, no zeroBaseFee, empty alloc, zeroed mixHash), so following that file yields a different chain. Two things it encodes that cost time to discover: - --bonsai-historical-block-limit cannot be raised on its own. Besu refuses to start unless --bonsai-trie-logs-pruning-window-size exceeds it, and the deployment-parameters doc tells operators to raise only the former. - generate-blockchain-config --to pointed at a bind mount fails Besu's own "Output directory already exists" check. It has to write inside the container and be copied out. cmd/packetconv translates a solidity-ibc-eureka SendPacket event into the protobuf channeltypesv2.Packet that MsgRecvPacket carries, and cross-checks the commitment. That check is the point: the commitment in Besu storage is computed by ICS24Host, the one cbdc-node verifies the proof against by channeltypesv2.CommitPacket. They agree byte for byte -- confirmed on real packets -- and if they ever stop agreeing nothing else in the stack would say why. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/packetconv/main.go | 155 ++++++++++++++++++++++++++++++++++++++ scripts/besu-devnet/up.sh | 117 ++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 cmd/packetconv/main.go create mode 100755 scripts/besu-devnet/up.sh diff --git a/cmd/packetconv/main.go b/cmd/packetconv/main.go new file mode 100644 index 00000000..75a0b920 --- /dev/null +++ b/cmd/packetconv/main.go @@ -0,0 +1,155 @@ +// 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") + flag.Parse() + + 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") + 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/scripts/besu-devnet/up.sh b/scripts/besu-devnet/up.sh new file mode 100755 index 00000000..7b1a5233 --- /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=10000 \ + --bonsai-trie-logs-pruning-window-size=20000 \ + >/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 From 5720d47a6b6ff0152499bf892e67561f7f66916b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 10:08:02 +0200 Subject: [PATCH 12/61] fix(scripts): seed inbound voucher metadata so tickers and decimals survive An EVM-native asset crossing ICS-20 arrives as its contract address, because the protocol carries only the denom string -- name, symbol and decimals are lost. ibc-go then synthesises metadata from what it has, with a single denom unit at exponent 0, so a token with 18 decimals on the spoke rendered 777 as 777000000000000000000 on cbdc-node. The erc20 middleware's auto-registered precompile inherited it, making MetaMask and every EVM tool wrong as well. There is no way to correct it after the fact: x/bank has no MsgSetDenomMetadata and neither does x/erc20. But ibc-go's receive path only writes metadata when none exists, and the voucher denom is deterministic -- sha256 of transfer//0x -- so it can be seeded before any packet moves. seed-voucher-metadata.sh does the derivation and patches genesis; local-node.sh gains an optional SEED_VOUCHER hook following the UNBONDING_TIME precedent, so the default is unchanged. Verified end to end on the bench rig. Before: display transfer/qbftclient-0/0xfe0b7ee2..., symbol 0XFE0B7EE2..., decimals 0. After a second real transfer with metadata seeded: display and symbol tCeBM_BRL, name "Test CeBM BRL", and the precompile reports decimals 18. The derivation was checked against reality rather than trusted -- the script's hash for the live trace matches the denom the chain actually minted. Co-Authored-By: Claude Opus 5 (1M context) --- local-node.sh | 11 +++++ scripts/seed-voucher-metadata.sh | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100755 scripts/seed-voucher-metadata.sh diff --git a/local-node.sh b/local-node.sh index 5bd78be6..d425e477 100755 --- a/local-node.sh +++ b/local-node.sh @@ -82,6 +82,17 @@ bin/cbdcd --home "$HOMEDIR" genesis gentx alice 1000000apoa --fees ${BASEFEE}axr bin/cbdcd --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/cbdcd --home "$HOMEDIR" genesis validate if [[ $1 == "pending" ]]; then diff --git a/scripts/seed-voucher-metadata.sh b/scripts/seed-voucher-metadata.sh new file mode 100755 index 00000000..b4de4049 --- /dev/null +++ b/scripts/seed-voucher-metadata.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Seed bank denom metadata for an inbound IBC voucher, before the first transfer. +# +# WHY THIS IS A DEPLOY STEP, NOT A FIX-UP +# +# ICS-20 carries only the denom string. Name, symbol and decimals never cross the +# boundary, so an EVM-native asset arrives as its contract address and ibc-go +# synthesises metadata from what little it has: +# +# display transfer//0x
+# symbol 0X
+# units [{denom: 0x
, exponent: 0}] <-- decimals collapse to 0 +# +# A token with 18 decimals on the spoke therefore renders 777 as +# 777000000000000000000 on cbdc-node, and the erc20 middleware's auto-registered +# precompile inherits it, so MetaMask and every EVM tool are wrong too. +# +# ibc-go's receive path is guarded -- +# +# if !k.BankKeeper.HasDenomMetaData(ctx, voucherDenom) { k.SetDenomMetadata(...) } +# +# -- so metadata that already exists is left alone. Seeding it first wins, and it +# needs no governance message, which matters because x/bank has none: +# MsgSetDenomMetadata does not exist. Post-genesis there is no way to correct this +# without new consensus code. +# +# The voucher denom is deterministic: sha256 of the trace, so it can be computed +# before any packet moves, given the client id and the spoke's token address. +# +# Usage: +# seed-voucher-metadata.sh <0xtoken> +# +# Example: +# seed-voucher-metadata.sh ~/.cbdcd/config/genesis.json \ +# qbftclient-0 0xfE0B7EE21e8298fC68b9Bf5f404e7df7B6671EC2 tCeBM_BRL "Test CeBM BRL" 18 +set -euo pipefail + +GENESIS="${1:?genesis.json}" +CLIENT_ID="${2:?client id, e.g. qbftclient-0}" +TOKEN="${3:?0x token address on the spoke}" +SYMBOL="${4:?symbol, e.g. tCeBM_BRL}" +NAME="${5:?human name}" +DECIMALS="${6:?decimals on the source chain}" + +# solidity-ibc-eureka sets the ICS-20 denom with Strings.toHexString(address), +# which is lowercase. The trace and therefore the hash depend on that exactly. +TOKEN_LC=$(echo "$TOKEN" | tr 'A-Z' 'a-z') +TRACE="transfer/${CLIENT_ID}/${TOKEN_LC}" +HASH=$(printf '%s' "$TRACE" | sha256sum | cut -d' ' -f1 | tr 'a-f' 'A-F') +DENOM="ibc/${HASH}" + +echo "trace $TRACE" +echo "denom $DENOM" + +TMP=$(mktemp) +jq --arg denom "$DENOM" --arg sym "$SYMBOL" --arg name "$NAME" \ + --arg trace "$TRACE" --argjson exp "$DECIMALS" ' + .app_state.bank.denom_metadata += [{ + description: ("Voucher for " + $sym + " received over " + $trace), + denom_units: [ + { denom: $denom, exponent: 0, aliases: [] }, + { denom: $sym, exponent: $exp, aliases: [] } + ], + base: $denom, + display: $sym, + name: $name, + symbol: $sym, + uri: "", + uri_hash: "" + }]' "$GENESIS" > "$TMP" && mv "$TMP" "$GENESIS" + +echo "seeded display=$SYMBOL symbol=$SYMBOL name=$NAME exponent=$DECIMALS" From bfbb85aaeacc2a40d3cec4bafe4b27c82fa6ddfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 11:17:02 +0200 Subject: [PATCH 13/61] feat(qbftrelay): add -as-timeout, and prove the timeout leg end to end msgs.Timeout and prover.PacketReceiptProof both existed and neither had ever been exercised. qbftrelay gains a mode that uses them: instead of proving a packet commitment is PRESENT on the counterparty, it proves the packet receipt is ABSENT, which is what entitles this chain to refund its own escrow. The receipt is keyed by the packet's DESTINATION client, not its source, because that is the store it would have been written into. Getting that backwards produces a proof of the wrong slot that verifies correctly and means nothing. Flag is -as-timeout, not -timeout: the testing package registers -timeout as a duration in any binary that links it, and this one does transitively via rapid. The collision surfaced as a usage dump rather than an error. Proven on the bench rig, cbdc-node -> Besu(Brazil): 1. cbdc-node sent an outbound v2 packet with a 30s absolute timeout, escrowing 1,000,000 axrp (total-escrow confirmed). 2. It was deliberately never relayed. 3. Waited for a Besu block timestamped past the deadline -- ibc-go requires the counterparty consensus state at the proof height to be at or after the packet timeout, so the proof height is bounded from below by wall clock, not just by the client's progress. 4. Built the MsgTimeout with a real QBFT non-membership proof of the receipt slot in Besu storage. 5. cbdc-node's real msg server accepted it: events timeout, timeout_packet, transfer. 6. total-escrow axrp back to 0, and the packet commitment is deleted. Only the OUTBOUND timeout is provable today. An inbound packet timing out would be timed out on Besu, verified by AttestationLightClient, which needs an attestor that does not exist yet -- the same gap that blocks the return and ack legs. This is the second of DEC-3's four templatisation requirements. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/qbftrelay/main.go | 49 ++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/cmd/qbftrelay/main.go b/cmd/qbftrelay/main.go index 5ffe9f3a..b8de3675 100644 --- a/cmd/qbftrelay/main.go +++ b/cmd/qbftrelay/main.go @@ -46,16 +46,17 @@ import ( 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") - 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") + 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") + 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() @@ -75,6 +76,7 @@ func main() { evmChain: *evmChain, gasLimit: *gasLimit, out: *out, + timeout: *timeoutMode, } if err := run(context.Background(), cfg, *packetHex); err != nil { @@ -93,6 +95,7 @@ type config struct { evmChain uint64 gasLimit uint64 out string + timeout bool } func run(ctx context.Context, cfg config, packetHex string) error { @@ -141,11 +144,29 @@ func run(ctx context.Context, cfg config, packetHex string) error { return err } - proof, err := p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, cfg.target) - if err != nil { - return err + // Two directions, two proofs. A receive proves the commitment is PRESENT in the + // counterparty's store; a timeout proves the receipt is ABSENT, which is what + // entitles this chain to refund its own escrow. The receipt is keyed by the + // packet's DESTINATION client, because that is the store it would have been + // written into. + var ( + proof *types.StorageProof + final sdk.Msg + ) + if cfg.timeout { + proof, err = p.PacketReceiptProof(ctx, packet.DestinationClient, packet.Sequence, cfg.target) + if err != nil { + return err + } + final, err = msgs.Timeout(encCfg.Codec, packet, proof, cfg.target, cfg.signer) + } else { + proof, err = p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, cfg.target) + if err != nil { + return err + } + final, err = msgs.RecvPacket(encCfg.Codec, packet, proof, cfg.target, cfg.signer) } - recv, err := msgs.RecvPacket(encCfg.Codec, packet, proof, cfg.target, cfg.signer) + recv := final if err != nil { return err } From f3a7822000dce074650707961fc7bbe90c1960e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 11:48:49 +0200 Subject: [PATCH 14/61] feat(packetconv): reverse conversion, and prove the return leg end to end packetconv gains --to-solidity: given a protobuf packet, emit the Solidity tuple the counterparty's ICS26Router expects. Needed to submit a cbdc-node packet to Besu, which is the return leg. Return leg proven on the bench rig, cbdc-node -> Besu(Brazil), with real contracts and real attestation: 1. 300 tCeBM_BRL escrowed on Besu, relayed in, voucher minted on cbdc-node. 2. Voucher sent back over qbftclient-1 and burned. 3. cosmos/ibc-attestor signed a StateAttestation; updateClient accepted it and stored the consensus timestamp. 4. The same attestor signed a PacketAttestation over the commitment. 5. ICS26Router.recvPacket accepted it: escrow 800 -> 500, and the recipient received the 300. So the full round trip now holds: escrow in, voucher out, voucher back, escrow released. That is the first of DEC-3's four templatisation requirements. Two findings recorded in the deployment parameters: The encoding mitigation is not actionable through the CLI. MsgTransfer carries an Encoding field and MarshalPacketData supports EncodingABI, but cbdcd tx ibc-transfer transfer has no --encoding flag, so it always sends the default that ibc-go resolves to application/json -- which the Solidity side cannot decode. The documented instruction to "set it explicitly" has no path through shipped tooling; completing the leg needed --generate-only plus a jq injection. It belongs in the relayer/issuer path, and a CLI flag is a one-line upstream contribution. ibc-attestor's PacketAttestation expects ABI-encoded Solidity packets, not the protobuf form cbdc-node emits. The protobuf form fails with "AbiError: type check failed for offset (usize)", which names neither the expected format nor the field. Also observed: the commitment stays open after the counterparty receives, because it clears on acknowledgement -- so the money completes before the lifecycle does. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/packetconv/main.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/packetconv/main.go b/cmd/packetconv/main.go index 75a0b920..babf29b3 100644 --- a/cmd/packetconv/main.go +++ b/cmd/packetconv/main.go @@ -64,8 +64,26 @@ 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) From a2e2324b86026192064cc7cef02d847dff409b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 30 Jul 2026 13:07:04 +0200 Subject: [PATCH 15/61] fix(genesis): expedited_min_deposit was unfundable; record the genesis-only lists local-node.sh rewrote min_deposit's denom to axrp and left expedited_min_deposit at the SDK default of 50000000 "stake" -- a denom this chain does not have. So an expedited proposal could never meet its deposit and the fast governance path was silently unusable. It was accepted rather than rejected because Params.ValidateBasic compares the two with IsAllLTE, which does not trip across disjoint denom sets. The config is valid and useless: it passes every check while making the fast path impossible to fund. A genesis that failed validation would have said so at boot. That matters because the fast path is what you reach for given DEC-14's accepted deadlock, DEC-16's fast stop being unbuilt, and DEC-19 launching without quotas. Set to 2 axrp -- not 1, since validation requires it to be strictly greater than min_deposit, so 2 is the smallest value consistent with wanting no restriction. min_deposit itself stays 1 axrp and is confirmed deliberate: zero is not expressible, because sdk.Coins normalises zero amounts away and Empty() is rejected. Also recorded in DEC-14: the rate-limiting module's denom blacklist and sender/receiver whitelist are genesis-only with no messages and nothing wiring them, so both must stay empty. The whitelist is the dangerous half -- it bypasses rate limiting entirely without recording the flow, so it fails open, permanently and invisibly. Co-Authored-By: Claude Opus 5 (1M context) --- local-node.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/local-node.sh b/local-node.sh index d425e477..223a7eac 100755 --- a/local-node.sh +++ b/local-node.sh @@ -51,6 +51,13 @@ 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" From a766d76d5facdcbcca9dfa83e44ace4fece54799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 08:44:11 +0200 Subject: [PATCH 16/61] chore(git): ignore pilot node homes and cbdcd backups .gitignore covered only `.cbdcd/`, so `.cbdcd-pilot/` and `.cbdcd.bak-20260730-084348/` were untracked-but-offered. Both are node homes and both contain `keyring-test`, which must never reach the repository. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index faf3b9d7..1e06fafb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ release/ .cbdcd/ +.cbdcd-*/ +.cbdcd.bak-*/ *.out *.html From bdd02df6f4fb1a504cac0a816ab7708e51a14289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 08:44:21 +0200 Subject: [PATCH 17/61] =?UTF-8?q?feat(cmd):=20sp1fixture=20=E2=80=94=20bui?= =?UTF-8?q?ld=20an=20SP1=20update-client=20fixture=20from=20a=20live=20nod?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instrument behind the spike's Stage 1 result: it assembles an SP1 update-client fixture from a running cbdc-node, which is what let the stock `SP1ICS07Tendermint` program be run against real headers rather than synthetic ones. It was untracked while the docs already listed it under "built and proven" (docs/README.md), so the evidence for DEC-24 existed on one disk only. Verified: `go build ./cmd/sp1fixture/` clean. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/sp1fixture/main.go | 203 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 cmd/sp1fixture/main.go diff --git a/cmd/sp1fixture/main.go b/cmd/sp1fixture/main.go new file mode 100644 index 00000000..7da55356 --- /dev/null +++ b/cmd/sp1fixture/main.go @@ -0,0 +1,203 @@ +// 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, + 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 + 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, 0o644); 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 +} From 603f7f4f2daddb45b98e0c144e79aa1f8d5e58b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 08:44:32 +0200 Subject: [PATCH 18/61] fix(besu-devnet): implement DEC-21's retention instead of approximating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rig ran `--bonsai-historical-block-limit=10000` / `--bonsai-trie-logs-pruning-window-size=20000` — about 5.5 hours at 2 s blocks, where DEC-21 decided 24 hours (43,200 blocks) for the Besu side. Retention is what bounds how far a relayer may lag: past the limit `eth_getProof` fails with "World state unavailable" and no proof can be produced for that height at all. Re-ran the rig to check the flags rather than assuming they parse: boots, produces blocks, no errors. Note Besu logs "Forcing --bonsai-limit-trie-logs-enabled=false, since it cannot be enabled with --sync-mode=FULL", so locally the pruning-window half is inert and the historical-block-limit is the flag doing the work. On LNET's spoke it will not be inert, so that ask is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/besu-devnet/up.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/besu-devnet/up.sh b/scripts/besu-devnet/up.sh index 7b1a5233..e78f4bb4 100755 --- a/scripts/besu-devnet/up.sh +++ b/scripts/besu-devnet/up.sh @@ -95,8 +95,8 @@ docker run -d --name "$CONTAINER" \ --host-allowlist="*" \ --rpc-http-cors-origins="*" \ --min-gas-price=0 \ - --bonsai-historical-block-limit=10000 \ - --bonsai-trie-logs-pruning-window-size=20000 \ + --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" From 8ac1d4bc468b3ac38d4209c0937491eb29842f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 09:13:19 +0200 Subject: [PATCH 19/61] test: run the unit tests CI was skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI enforced 4 of the 13 packages that have tests. `make test-poa` resolves to ./x/poa/... only, so the Dockerfile's integration stage — which is what pull-request.yml builds — never ran the unit tests for x/qbftclient, x/qbftclient/prover, x/cbdc or app/ibc/corridorpause. `make test` had the same hole. That is the verification core DEC-28 cites as what replaced the external audit, the module 0.4b's pauser is about to land in, and the six test cases DEC-16 argued made the pauser cheap. Adds a test-unit target over the existing EXCLUDED_UNIT_PACKAGES, puts it in `make test` in place of test-poa (a subset of it), and calls it from the Dockerfile. CI now runs 15 packages. Also anchors that variable's `grep -v app`, which was dropping app/ibc/corridorpause from unit coverage as well as the app package it meant to exclude. ./app stays out: its only test is TestFullAppSimulation, which needs the -Enabled/-NumBlocks/-Params flags the test-sim-* targets pass and panics without them. Verified: `make test-unit` green across all 15 packages. Requirement 1 of DEC-29's assurance bar. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 5 +++-- Makefile | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9c9ddecb..ec70a0ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,9 @@ RUN make build FROM base AS integration RUN make lint -# Unit tests -RUN make test-poa +# 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 diff --git a/Makefile b/Makefile index 7608e586..732b98e1 100644 --- a/Makefile +++ b/Makefile @@ -132,7 +132,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 +144,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) From f6c88278c22d2f87e4847b400397f19c752fed0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 12:33:45 +0200 Subject: [PATCH 20/61] feat(corridor): attestor sidecar, automated relaying, and the proof-api groundwork Three components, built to close the gap between "the corridor works when I type" and "the corridor runs". x/qbftclient/attestor is the signing core for the outbound leg. Its encodings are pinned to the deployed contract rather than to themselves: the digest is sha256(tag || sha256(abi.encode(struct))) with the tag over the INNER hash, signed as a raw digest -- not EIP-191, since the contract calls ECDSA.recover directly -- and v shifted into OpenZeppelin's 27/28 range. Golden values come from cast, which was verified against the live client. Upstream's IBC_ATTESTOR_DESIGN.md is stale on both points and the package doc says so. cmd/qbftattestor is the attestor sidecar, and it exists as a separate process for one reason: an attestor never signs what it is told. It takes a height and a set of paths, reads the timestamp and every commitment from its own view of cbdc-node, and signs only that. Asking it to attest a packet the chain cannot see is refused. It also refuses a second, different timestamp for a height it has already attested, which is the only path to a terminal client freeze. It serves both an HTTP API and upstream's AggregatorService gRPC, so cosmos/ibc-relayer's cosmos-to-eth can consume it directly and ibc-attestor need not replace it. cmd/corridord drives both legs automatically. It holds no attestor key -- it asks the sidecar -- and no cbdc-node key: the inbound leg emits an unsigned tx and lets the chain's own tooling sign it, which is the boundary DEC-7 draws. It is a rig tool and says so; DEC-18 names cosmos/ibc-relayer as the driver, and this deviates deliberately because that path needs three components that do not exist yet. Also here: generated Go types for upstream's aggregator and proof-api services, with hand-written gRPC glue for the aggregator because the proto-builder image ships protoc-gen-go but not protoc-gen-go-grpc. local-node.sh gains SKIP_START so genesis can be seeded between init and start. That window is where denom metadata must be written -- ibc-go only synthesises voucher metadata if none exists and x/bank has no MsgSetDenomMetadata, so after start there is no second chance. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/chains.go | 328 ++++++++++++++++ cmd/corridord/main.go | 242 ++++++++++++ cmd/qbftattestor/cbdc.go | 122 ++++++ cmd/qbftattestor/grpc.go | 116 ++++++ cmd/qbftattestor/main.go | 201 ++++++++++ local-node.sh | 14 +- proto/aggregator/aggregator.proto | 41 ++ .../attestor/aggregatorpb/aggregator.pb.go | 354 ++++++++++++++++++ x/qbftclient/attestor/aggregatorpb/service.go | 72 ++++ x/qbftclient/attestor/attestation.go | 165 ++++++++ x/qbftclient/attestor/attestation_test.go | 107 ++++++ 11 files changed, 1760 insertions(+), 2 deletions(-) create mode 100644 cmd/corridord/chains.go create mode 100644 cmd/corridord/main.go create mode 100644 cmd/qbftattestor/cbdc.go create mode 100644 cmd/qbftattestor/grpc.go create mode 100644 cmd/qbftattestor/main.go create mode 100644 proto/aggregator/aggregator.proto create mode 100644 x/qbftclient/attestor/aggregatorpb/aggregator.pb.go create mode 100644 x/qbftclient/attestor/aggregatorpb/service.go create mode 100644 x/qbftclient/attestor/attestation.go create mode 100644 x/qbftclient/attestor/attestation_test.go diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go new file mode 100644 index 00000000..8fe28f4b --- /dev/null +++ b/cmd/corridord/chains.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Both clients shell out to cast/cbdcd 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 } + +type sendPacket struct { + sequence uint64 + txHash string +} + +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. +func (b *besuClient) sendPackets(ctx context.Context, router string) ([]sendPacket, error) { + // keccak256("SendPacket(string,uint256,(uint64,string,string,uint64,(string,string,string,string,bytes)[]))") + const topic = "0xab3a4458a269be61dfa43faa33aa7b1f5d570716f83ad078bc2ba5dab039abae" + res, err := b.call(ctx, "eth_getLogs", []any{map[string]any{ + "address": router, "fromBlock": "0x0", "toBlock": "latest", "topics": []any{topic}, + }}) + if err != nil { + return nil, err + } + var logs []struct { + Topics []string `json:"topics"` + TxHash string `json:"transactionHash"` + } + if err := json.Unmarshal(res, &logs); err != nil { + return nil, err + } + out := make([]sendPacket, 0, len(logs)) + for _, l := range logs { + if len(l.Topics) < 3 { + 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 +} + +// 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) { + out, err := run(ctx, "cast", "call", "-r", b.rpc, router, + "getCommitment(bytes32)(bytes32)", receiptCommitmentKey(clientID, seq)) + if err != nil { + return false, nil + } + 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 +} + +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() + 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 txSearch struct { + Result struct { + Txs []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"` + } `json:"txs"` + } `json:"result"` +} + +func (c *cbdcRPC) sendEvents(ctx context.Context, clientID string) (map[uint64]string, error) { + q := url.Values{} + q.Set("query", fmt.Sprintf("\"send_packet.packet_source_client='%s'\"", clientID)) + q.Set("per_page", "100") + var out txSearch + if err := c.get(ctx, "/tx_search?"+q.Encode(), &out); err != nil { + return nil, err + } + res := map[uint64]string{} + for _, tx := range out.Result.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 +} + +func (c *cbdcRPC) pendingSequences(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) { + path := receiptPath(clientID, seq) + q := url.Values{} + q.Set("path", `"store/ibc/key"`) + q.Set("data", "0x"+hex.EncodeToString(path)) + var out struct { + Result struct { + Response struct { + Value string `json:"value"` + } `json:"response"` + } `json:"result"` + } + if err := c.get(ctx, "/abci_query?"+q.Encode(), &out); err != nil { + return false, err + } + return out.Result.Response.Value != "", nil +} + +// receiptPath is clientID || 0x02 || be64(seq) -- kind 2 is the receipt. +func receiptPath(clientID string, seq uint64) []byte { + p := append([]byte(clientID), 0x02) + var be [8]byte + for i := 0; i < 8; i++ { + be[7-i] = byte(seq >> (8 * i)) + } + return append(p, be[:]...) +} + +func receiptCommitmentKey(clientID string, seq uint64) string { + return "0x" + hex.EncodeToString(receiptPath(clientID, seq)) +} + +// 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; cbdcd 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") + } + + 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) + + unsigned := fmt.Sprintf("/tmp/corridord-recv-%d.json", p.sequence) + if _, err := run(ctx, "go", "run", "./cmd/qbftrelay", + "-besu-rpc", d.cfg.besuRPC, "-contract", d.cfg.router, "-client-id", d.cfg.cbdcCli, + "-packet-hex", pkt, "-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); err != nil { + return fmt.Errorf("qbftrelay: %w", err) + } + + signed := unsigned + ".signed" + if _, err := run(ctx, "./bin/cbdcd", "--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/cbdcd", "--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/cbdcd", "--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/main.go b/cmd/corridord/main.go new file mode 100644 index 00000000..3d1c0a0d --- /dev/null +++ b/cmd/corridord/main.go @@ -0,0 +1,242 @@ +// 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" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "time" +) + +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", "", "cbdcd home directory") + flag.StringVar(&cfg.cbdcChain, "cbdc-chain-id", "cbdc-honduras_5040000-1", "cosmos chain id") + flag.StringVar(&cfg.keyName, "key-name", "alice", "cbdcd 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{}, + 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: + if err := d.tick(ctx); err != nil { + log.Printf("tick: %v", err) + } + } + } +} + +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 + attested map[uint64]bool // heights already pushed to the light client +} + +func (d *driver) tick(ctx context.Context) error { + if err := d.outbound(ctx); err != nil { + log.Printf("outbound: %v", err) + } + if err := d.inbound(ctx); err != nil { + log.Printf("inbound: %v", err) + } + return nil +} + +// 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.pendingSequences(ctx, d.cfg.cbdcCli) + if err != nil { + return err + } + for _, seq := range seqs { + if d.doneOut[seq] { + continue + } + if recvd, _ := d.besu.packetReceived(ctx, d.cfg.router, d.cfg.besuCli, seq); recvd { + 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 !d.attested[h] { + 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("outbound: client advanced to cbdc height %d", h) + } + 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 +} + +// 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) + if err != nil { + return err + } + for _, p := range packets { + if d.doneIn[p.sequence] { + continue + } + if got, _ := d.cbdc.packetReceived(ctx, d.cfg.cbdcCli, p.sequence); got { + 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 +} + +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 { + msg := make([]byte, 512) + n, _ := resp.Body.Read(msg) + return nil, fmt.Errorf("attestor refused (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg[:n]))) + } + 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 (cbdcd). +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/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go new file mode 100644 index 00000000..8b0d4ff1 --- /dev/null +++ b/cmd/qbftattestor/cbdc.go @@ -0,0 +1,122 @@ +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) + } + return uint64(t.Unix()), 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 +} + +// 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/grpc.go b/cmd/qbftattestor/grpc.go new file mode 100644 index 00000000..fc2e4573 --- /dev/null +++ b/cmd/qbftattestor/grpc.go @@ -0,0 +1,116 @@ +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" +) + +// 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. + a.s.mu.Lock() + if prev, ok := a.s.seen[height]; ok && prev != ts { + a.s.mu.Unlock() + return nil, fmt.Errorf( + "refusing: already attested height %d as %d, now reading %d -- signing both would freeze the client permanently", + height, prev, ts) + } + a.s.seen[height] = ts + a.s.mu.Unlock() + + 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 commitment paths to attest. We hash them + // ourselves and read each commitment from our own node -- a caller cannot + // smuggle in a commitment value. + 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}) + log.Printf("aggregator gRPC (AggregatorService) on %s", addr) + return srv.Serve(lis) +} diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go new file mode 100644 index 00000000..f73b0a79 --- /dev/null +++ b/cmd/qbftattestor/main.go @@ -0,0 +1,201 @@ +// 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 theatre -- 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. This process therefore remembers every height +// it has attested and refuses to sign a second, different timestamp for it -- +// even across a restart it re-derives from the chain, which is deterministic. +package main + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "sync" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/peersyst/cbdc-node/x/qbftclient/attestor" +) + +type server struct { + key *ecdsa.PrivateKey + chain *cbdcClient + client string // the cbdc-node client id packets are sent from + + mu sync.Mutex + seen map[uint64]uint64 // height -> timestamp already attested +} + +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") + ) + 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") + } + key, err := crypto.HexToECDSA(trim0x(k)) + if err != nil { + log.Fatalf("bad key: %v", err) + } + + s := &server{ + key: key, + chain: &cbdcClient{rpc: *rpc}, + client: *clientID, + seen: map[uint64]uint64{}, + } + + log.Printf("attestor %s", crypto.PubkeyToAddress(key.PublicKey)) + log.Printf("verifying against %s, signing for client %s", *rpc, *clientID) + 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("/address", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]string{"address": crypto.PubkeyToAddress(key.PublicKey).Hex()}) + }) + log.Fatal(http.ListenAndServe(*listen, nil)) +} + +// 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. + s.mu.Lock() + if prev, ok := s.seen[req.Height]; ok && prev != ts { + s.mu.Unlock() + http.Error(w, fmt.Sprintf( + "REFUSING: already attested height %d as %d, now reading %d -- signing both would freeze the client permanently", + req.Height, prev, ts), http.StatusConflict) + return + } + s.seen[req.Height] = ts + s.mu.Unlock() + + 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. +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)}) +} + +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)) +} + +var _ = context.Background diff --git a/local-node.sh b/local-node.sh index 223a7eac..52ad130b 100755 --- a/local-node.sh +++ b/local-node.sh @@ -3,7 +3,9 @@ # declare bash explicitly -- without this it runs under /bin/sh, which is dash on # Debian-based images and fails on those constructs. -CHAINID="cbdc_1449999-1" +# 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 @@ -12,7 +14,7 @@ KEYRING="test" KEYALGO="eth_secp256k1" LOGLEVEL="info" # Set dedicated home directory for the evmosd instance -HOMEDIR="$PWD/.cbdcd" +HOMEDIR="${HOMEDIR:-$PWD/.cbdcd}" # to trace evm #TRACE="--trace" TRACE="" @@ -141,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/cbdcd start \ --metrics "$TRACE" \ --log_level $LOGLEVEL \ diff --git a/proto/aggregator/aggregator.proto b/proto/aggregator/aggregator.proto new file mode 100644 index 00000000..d309ce1a --- /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/x/qbftclient/attestor/aggregatorpb/aggregator.pb.go b/x/qbftclient/attestor/aggregatorpb/aggregator.pb.go new file mode 100644 index 00000000..114daef6 --- /dev/null +++ b/x/qbftclient/attestor/aggregatorpb/aggregator.pb.go @@ -0,0 +1,354 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc (unknown) +// source: aggregator/aggregator.proto + +package aggregatorpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Request message for getting an attestation for a set of packets. +type GetAttestationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The packets to attest to + Packets [][]byte `protobuf:"bytes,1,rep,name=packets,proto3" json:"packets,omitempty"` + // The height to attest to the packets at + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` +} + +func (x *GetAttestationsRequest) Reset() { + *x = GetAttestationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aggregator_aggregator_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAttestationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAttestationsRequest) ProtoMessage() {} + +func (x *GetAttestationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aggregator_aggregator_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAttestationsRequest.ProtoReflect.Descriptor instead. +func (*GetAttestationsRequest) Descriptor() ([]byte, []int) { + return file_aggregator_aggregator_proto_rawDescGZIP(), []int{0} +} + +func (x *GetAttestationsRequest) GetPackets() [][]byte { + if x != nil { + return x.Packets + } + return nil +} + +func (x *GetAttestationsRequest) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +// One instance of an attestation with all signatures and public keys +type AggregatedAttestation struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The height of the attestation + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + // The timestamp of the block + Timestamp *uint64 `protobuf:"varint,2,opt,name=timestamp,proto3,oneof" json:"timestamp,omitempty"` + // The attested data + AttestedData []byte `protobuf:"bytes,3,opt,name=attested_data,json=attestedData,proto3" json:"attested_data,omitempty"` + // The attestation signatures + Signatures [][]byte `protobuf:"bytes,4,rep,name=signatures,proto3" json:"signatures,omitempty"` +} + +func (x *AggregatedAttestation) Reset() { + *x = AggregatedAttestation{} + if protoimpl.UnsafeEnabled { + mi := &file_aggregator_aggregator_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AggregatedAttestation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AggregatedAttestation) ProtoMessage() {} + +func (x *AggregatedAttestation) ProtoReflect() protoreflect.Message { + mi := &file_aggregator_aggregator_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AggregatedAttestation.ProtoReflect.Descriptor instead. +func (*AggregatedAttestation) Descriptor() ([]byte, []int) { + return file_aggregator_aggregator_proto_rawDescGZIP(), []int{1} +} + +func (x *AggregatedAttestation) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *AggregatedAttestation) GetTimestamp() uint64 { + if x != nil && x.Timestamp != nil { + return *x.Timestamp + } + return 0 +} + +func (x *AggregatedAttestation) GetAttestedData() []byte { + if x != nil { + return x.AttestedData + } + return nil +} + +func (x *AggregatedAttestation) GetSignatures() [][]byte { + if x != nil { + return x.Signatures + } + return nil +} + +// GetStateAttestationResponse is a response from the aggregator. +type GetAttestationsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The attestation of the blockchain state + StateAttestation *AggregatedAttestation `protobuf:"bytes,1,opt,name=state_attestation,json=stateAttestation,proto3" json:"state_attestation,omitempty"` + // The attestation of the packet membership at the attested state + PacketAttestation *AggregatedAttestation `protobuf:"bytes,2,opt,name=packet_attestation,json=packetAttestation,proto3" json:"packet_attestation,omitempty"` +} + +func (x *GetAttestationsResponse) Reset() { + *x = GetAttestationsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aggregator_aggregator_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAttestationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAttestationsResponse) ProtoMessage() {} + +func (x *GetAttestationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aggregator_aggregator_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAttestationsResponse.ProtoReflect.Descriptor instead. +func (*GetAttestationsResponse) Descriptor() ([]byte, []int) { + return file_aggregator_aggregator_proto_rawDescGZIP(), []int{2} +} + +func (x *GetAttestationsResponse) GetStateAttestation() *AggregatedAttestation { + if x != nil { + return x.StateAttestation + } + return nil +} + +func (x *GetAttestationsResponse) GetPacketAttestation() *AggregatedAttestation { + if x != nil { + return x.PacketAttestation + } + return nil +} + +var File_aggregator_aggregator_proto protoreflect.FileDescriptor + +var file_aggregator_aggregator_proto_rawDesc = []byte{ + 0x0a, 0x1b, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, 0x72, 0x2f, 0x61, 0x67, 0x67, + 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x61, + 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x4a, 0x0a, 0x16, 0x47, 0x65, 0x74, + 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x16, 0x0a, + 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x68, + 0x65, 0x69, 0x67, 0x68, 0x74, 0x22, 0xa5, 0x01, 0x0a, 0x15, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x21, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x48, 0x00, 0x52, 0x09, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x88, 0x01, 0x01, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x74, + 0x74, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0c, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x65, 0x64, 0x44, 0x61, 0x74, 0x61, 0x12, + 0x1e, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x04, 0x20, + 0x03, 0x28, 0x0c, 0x52, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0xbb, 0x01, + 0x0a, 0x17, 0x47, 0x65, 0x74, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x11, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x5f, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, + 0x72, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x74, 0x65, + 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x10, 0x73, 0x74, 0x61, 0x74, 0x65, 0x41, 0x74, + 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x50, 0x0a, 0x12, 0x70, 0x61, 0x63, + 0x6b, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, + 0x6f, 0x72, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x74, + 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x11, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, + 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x32, 0x6f, 0x0a, 0x11, 0x41, + 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x12, 0x5a, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x12, 0x22, 0x2e, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, 0x72, + 0x2e, 0x47, 0x65, 0x74, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x42, 0x5a, 0x40, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x65, 0x65, 0x72, 0x73, + 0x79, 0x73, 0x74, 0x2f, 0x63, 0x62, 0x64, 0x63, 0x2d, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x78, 0x2f, + 0x71, 0x62, 0x66, 0x74, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x74, 0x74, 0x65, 0x73, + 0x74, 0x6f, 0x72, 0x2f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x6f, 0x72, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aggregator_aggregator_proto_rawDescOnce sync.Once + file_aggregator_aggregator_proto_rawDescData = file_aggregator_aggregator_proto_rawDesc +) + +func file_aggregator_aggregator_proto_rawDescGZIP() []byte { + file_aggregator_aggregator_proto_rawDescOnce.Do(func() { + file_aggregator_aggregator_proto_rawDescData = protoimpl.X.CompressGZIP(file_aggregator_aggregator_proto_rawDescData) + }) + return file_aggregator_aggregator_proto_rawDescData +} + +var file_aggregator_aggregator_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_aggregator_aggregator_proto_goTypes = []interface{}{ + (*GetAttestationsRequest)(nil), // 0: aggregator.GetAttestationsRequest + (*AggregatedAttestation)(nil), // 1: aggregator.AggregatedAttestation + (*GetAttestationsResponse)(nil), // 2: aggregator.GetAttestationsResponse +} +var file_aggregator_aggregator_proto_depIdxs = []int32{ + 1, // 0: aggregator.GetAttestationsResponse.state_attestation:type_name -> aggregator.AggregatedAttestation + 1, // 1: aggregator.GetAttestationsResponse.packet_attestation:type_name -> aggregator.AggregatedAttestation + 0, // 2: aggregator.AggregatorService.GetAttestations:input_type -> aggregator.GetAttestationsRequest + 2, // 3: aggregator.AggregatorService.GetAttestations:output_type -> aggregator.GetAttestationsResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_aggregator_aggregator_proto_init() } +func file_aggregator_aggregator_proto_init() { + if File_aggregator_aggregator_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aggregator_aggregator_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAttestationsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aggregator_aggregator_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AggregatedAttestation); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aggregator_aggregator_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAttestationsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aggregator_aggregator_proto_msgTypes[1].OneofWrappers = []interface{}{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aggregator_aggregator_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aggregator_aggregator_proto_goTypes, + DependencyIndexes: file_aggregator_aggregator_proto_depIdxs, + MessageInfos: file_aggregator_aggregator_proto_msgTypes, + }.Build() + File_aggregator_aggregator_proto = out.File + file_aggregator_aggregator_proto_rawDesc = nil + file_aggregator_aggregator_proto_goTypes = nil + file_aggregator_aggregator_proto_depIdxs = nil +} diff --git a/x/qbftclient/attestor/aggregatorpb/service.go b/x/qbftclient/attestor/aggregatorpb/service.go new file mode 100644 index 00000000..c5a11ec5 --- /dev/null +++ b/x/qbftclient/attestor/aggregatorpb/service.go @@ -0,0 +1,72 @@ +package aggregatorpb + +// gRPC glue for AggregatorService, hand-written because the proto-builder image +// ships protoc-gen-go but not protoc-gen-go-grpc. The service has exactly one +// method, so the generated equivalent would be this. +// +// This is the interface upstream's `cosmos-to-eth` calls in Attested mode to +// collect signatures for the outbound leg. Implementing it is what lets +// cosmos/ibc-relayer drive that direction without a first-party relayer. + +import ( + context "context" + + grpc "google.golang.org/grpc" +) + +const serviceName = "aggregator.AggregatorService" + +// AggregatorServiceServer is the server API for AggregatorService. +type AggregatorServiceServer interface { + GetAttestations(context.Context, *GetAttestationsRequest) (*GetAttestationsResponse, error) +} + +// AggregatorServiceClient is the client API for AggregatorService. +type AggregatorServiceClient interface { + GetAttestations(ctx context.Context, in *GetAttestationsRequest, opts ...grpc.CallOption) (*GetAttestationsResponse, error) +} + +type aggregatorServiceClient struct{ cc grpc.ClientConnInterface } + +// NewAggregatorServiceClient returns a client for AggregatorService. +func NewAggregatorServiceClient(cc grpc.ClientConnInterface) AggregatorServiceClient { + return &aggregatorServiceClient{cc} +} + +func (c *aggregatorServiceClient) GetAttestations(ctx context.Context, in *GetAttestationsRequest, opts ...grpc.CallOption) (*GetAttestationsResponse, error) { + out := new(GetAttestationsResponse) + if err := c.cc.Invoke(ctx, "/"+serviceName+"/GetAttestations", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +// RegisterAggregatorServiceServer registers an implementation with a gRPC server. +func RegisterAggregatorServiceServer(s grpc.ServiceRegistrar, srv AggregatorServiceServer) { + s.RegisterService(&AggregatorService_ServiceDesc, srv) +} + +func handlerGetAttestations(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(GetAttestationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AggregatorServiceServer).GetAttestations(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/GetAttestations"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(AggregatorServiceServer).GetAttestations(ctx, req.(*GetAttestationsRequest)) + }) +} + +// AggregatorService_ServiceDesc is the grpc.ServiceDesc for AggregatorService. +var AggregatorService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: serviceName, + HandlerType: (*AggregatorServiceServer)(nil), + Methods: []grpc.MethodDesc{ + {MethodName: "GetAttestations", Handler: handlerGetAttestations}, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "aggregator/aggregator.proto", +} diff --git a/x/qbftclient/attestor/attestation.go b/x/qbftclient/attestor/attestation.go new file mode 100644 index 00000000..02d6245d --- /dev/null +++ b/x/qbftclient/attestor/attestation.go @@ -0,0 +1,165 @@ +// Package attestor builds and signs the attestations that +// solidity-ibc-eureka's AttestationLightClient verifies on the spoke. +// +// It exists because the outbound leg of the corridor is not light-client IBC. +// Under DEC-30 the spoke verifies cbdc-node by m-of-n signatures rather than by +// consensus, so something has to produce those signatures -- there is no proof +// to relay, only an assertion to sign. +// +// The encodings here are not inferred. They mirror the contract byte for byte: +// +// digest = sha256( tag || sha256(abi.encode(attestation)) ) +// +// with tag 0x01 for state attestations and 0x02 for packet attestations, signed +// as a RAW digest -- not EIP-191. The contract calls ECDSA.recover on the digest +// directly, so a personal-sign prefix recovers the wrong address and the client +// rejects it as an unknown signer. +// +// NOTE: the upstream IBC_ATTESTOR_DESIGN.md is stale on both points. It documents +// the digest as a plain sha256 of the data with no domain tag, and the packet path +// hash as sha256. The deployed contract uses the tag byte above and keccak256 for +// the path. The contract is authoritative; the design doc is not. +package attestor + +import ( + "crypto/ecdsa" + "crypto/sha256" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// Domain separation tags, from AttestationLightClient.sol. +const ( + TagState byte = 0x01 + TagPacket byte = 0x02 +) + +// PacketCompact is one attested (path, commitment) pair. Path is the keccak256 +// of the FULL ICS-24 path, which for a one-element merkle prefix is exactly +// sourceClient || 0x01 || be64(sequence). +type PacketCompact struct { + Path [32]byte + Commitment [32]byte +} + +var ( + uint64Ty, _ = abi.NewType("uint64", "", nil) + bytesTy, _ = abi.NewType("bytes", "", nil) + bytesArrTy, _ = abi.NewType("bytes[]", "", nil) + + stateTupleTy, _ = abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "height", Type: "uint64"}, + {Name: "timestamp", Type: "uint64"}, + }) + packetTupleTy, _ = abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "height", Type: "uint64"}, + {Name: "packets", Type: "tuple[]", Components: []abi.ArgumentMarshaling{ + {Name: "path", Type: "bytes32"}, + {Name: "commitment", Type: "bytes32"}, + }}, + }) + proofTupleTy, _ = abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "attestationData", Type: "bytes"}, + {Name: "signatures", Type: "bytes[]"}, + }) +) + +// EncodeState returns abi.encode(StateAttestation{height, timestamp}). +func EncodeState(height, timestamp uint64) ([]byte, error) { + args := abi.Arguments{{Type: stateTupleTy}} + return args.Pack(struct { + Height uint64 `abi:"height"` + Timestamp uint64 `abi:"timestamp"` + }{height, timestamp}) +} + +// EncodePackets returns abi.encode(PacketAttestation{height, packets}). +func EncodePackets(height uint64, packets []PacketCompact) ([]byte, error) { + args := abi.Arguments{{Type: packetTupleTy}} + return args.Pack(struct { + Height uint64 `abi:"height"` + Packets []PacketCompact `abi:"packets"` + }{height, packets}) +} + +// Digest is the value an attestor signs: sha256(tag || sha256(data)). +// +// The inner hash is taken first and the tag prepended to the RESULT, not to the +// data. Getting that order backwards produces a digest that verifies against +// nothing and is indistinguishable from a wrong key. +func Digest(data []byte, tag byte) [32]byte { + inner := sha256.Sum256(data) + return sha256.Sum256(append([]byte{tag}, inner[:]...)) +} + +// Sign produces the 65-byte r||s||v signature the contract expects. +// +// go-ethereum emits v as 0/1; OpenZeppelin's ECDSA.recover requires 27/28 and +// rejects anything else, so the shift is mandatory rather than cosmetic. +func Sign(key *ecdsa.PrivateKey, digest [32]byte) ([]byte, error) { + sig, err := crypto.Sign(digest[:], key) + if err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + if len(sig) != 65 { + return nil, fmt.Errorf("expected 65-byte signature, got %d", len(sig)) + } + sig[64] += 27 + return sig, nil +} + +// EncodeProof returns abi.encode(AttestationProof{data, signatures}), which is +// what both updateClient and the router's proofCommitment field carry. +func EncodeProof(data []byte, signatures [][]byte) ([]byte, error) { + args := abi.Arguments{{Type: proofTupleTy}} + return args.Pack(struct { + AttestationData []byte `abi:"attestationData"` + Signatures [][]byte `abi:"signatures"` + }{data, signatures}) +} + +// StateProof builds a signed update-client message for (height, timestamp). +func StateProof(key *ecdsa.PrivateKey, height, timestamp uint64) ([]byte, error) { + data, err := EncodeState(height, timestamp) + if err != nil { + return nil, fmt.Errorf("encode state: %w", err) + } + sig, err := Sign(key, Digest(data, TagState)) + if err != nil { + return nil, err + } + return EncodeProof(data, [][]byte{sig}) +} + +// PacketProof builds a signed membership proof for the given packets at height. +func PacketProof(key *ecdsa.PrivateKey, height uint64, packets []PacketCompact) ([]byte, error) { + data, err := EncodePackets(height, packets) + if err != nil { + return nil, fmt.Errorf("encode packets: %w", err) + } + sig, err := Sign(key, Digest(data, TagPacket)) + if err != nil { + return nil, err + } + return EncodeProof(data, [][]byte{sig}) +} + +// CommitmentPath returns the full ICS-24 commitment path for a packet: +// sourceClient || 0x01 || be64(sequence). The spoke's registered merkle prefix +// is a single EMPTY element, so this is exactly what the light client hashes. +func CommitmentPath(sourceClient string, sequence uint64) []byte { + p := append([]byte(sourceClient), 0x01) + var be [8]byte + for i := 0; i < 8; i++ { + be[7-i] = byte(sequence >> (8 * i)) + } + return append(p, be[:]...) +} + +// PathHash is the value that goes in PacketCompact.Path. +func PathHash(sourceClient string, sequence uint64) [32]byte { + return common.BytesToHash(crypto.Keccak256(CommitmentPath(sourceClient, sequence))) +} diff --git a/x/qbftclient/attestor/attestation_test.go b/x/qbftclient/attestor/attestation_test.go new file mode 100644 index 00000000..e02711d2 --- /dev/null +++ b/x/qbftclient/attestor/attestation_test.go @@ -0,0 +1,107 @@ +package attestor + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +// The golden values below were produced by the `cast` pipeline that was verified +// against the DEPLOYED AttestationLightClient on the rig: the same bytes drove a +// successful updateClient and a verifyMembership that returned the stored +// timestamp. They pin this package to the contract, not to itself. +const ( + goldenHeight = uint64(123) + goldenTimestamp = uint64(1785491108) + // cast abi-encode 'f((uint64,uint64))' "(123,1785491108)" + goldenStateABI = "000000000000000000000000000000000000000000000000000000000000007b" + + "000000000000000000000000000000000000000000000000000000006a6c6ea4" +) + +func TestEncodeState_MatchesCast(t *testing.T) { + got, err := EncodeState(goldenHeight, goldenTimestamp) + require.NoError(t, err) + require.Equal(t, goldenStateABI, hex.EncodeToString(got)) +} + +// The tag is prepended to the INNER HASH, not to the data. Both orderings +// produce a 32-byte digest and only one of them verifies, so this is pinned +// rather than left to reading. +func TestDigest_TagAppliesToInnerHash(t *testing.T) { + data := []byte("payload") + got := Digest(data, TagState) + + inner := sha256Sum(data) + want := sha256Sum(append([]byte{TagState}, inner...)) + require.Equal(t, want, got[:]) + + // The wrong order -- tag over the raw data -- must differ. + wrong := sha256Sum(sha256Sum(append([]byte{TagState}, data...))) + require.NotEqual(t, wrong, got[:]) +} + +func TestDigest_TagsAreDomainSeparated(t *testing.T) { + data := []byte("same bytes, different meaning") + require.NotEqual(t, Digest(data, TagState), Digest(data, TagPacket), + "a state attestation must not be replayable as a packet attestation") +} + +// OpenZeppelin's ECDSA.recover rejects v outside {27,28}; go-ethereum emits +// {0,1}. If the shift is ever dropped every signature silently becomes an +// unknown signer. +func TestSign_UsesEthereumVRange(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + + sig, err := Sign(key, Digest([]byte("x"), TagState)) + require.NoError(t, err) + require.Len(t, sig, 65) + require.Contains(t, []byte{27, 28}, sig[64]) +} + +func TestSign_RecoversToTheAttestorAddress(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + want := crypto.PubkeyToAddress(key.PublicKey) + + digest := Digest([]byte("attestation"), TagPacket) + sig, err := Sign(key, digest) + require.NoError(t, err) + + // Undo the OZ shift the way the contract's recover does. + recoverable := make([]byte, 65) + copy(recoverable, sig) + recoverable[64] -= 27 + + pub, err := crypto.SigToPub(digest[:], recoverable) + require.NoError(t, err) + require.Equal(t, want, crypto.PubkeyToAddress(*pub)) +} + +// The full ICS-24 path is what the light client hashes, because the spoke's +// registered merkle prefix is a single empty element. +func TestCommitmentPath_Layout(t *testing.T) { + got := CommitmentPath("qbftclient-0", 1) + require.Equal(t, "71626674636c69656e742d30010000000000000001", hex.EncodeToString(got), + "must be sourceClient || 0x01 || be64(sequence)") +} + +func TestPacketProof_RoundTrips(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + + proof, err := PacketProof(key, 42, []PacketCompact{{ + Path: PathHash("qbftclient-0", 1), + Commitment: [32]byte{0xac, 0x17}, + }}) + require.NoError(t, err) + require.NotEmpty(t, proof) +} + +func sha256Sum(b []byte) []byte { + sum := sha256.Sum256(b) + return sum[:] +} From 3ef9bf047ef36d975a3d7e5428db99854d3d3c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 12:33:58 +0200 Subject: [PATCH 21/61] feat(corridor): generated proof-api types for the ibc-relayer shim The shim (cmd/qbftproofapi) is the gate on using cosmos/ibc-relayer, because its proof-API endpoint is a single global config field rather than a per-route map, so one service must answer for both directions. Co-Authored-By: Claude Opus 5 (1M context) --- x/qbftclient/proofapipb/proofapi.pb.go | 1277 ++++++++++++++++++++++++ 1 file changed, 1277 insertions(+) create mode 100644 x/qbftclient/proofapipb/proofapi.pb.go diff --git a/x/qbftclient/proofapipb/proofapi.pb.go b/x/qbftclient/proofapipb/proofapi.pb.go new file mode 100644 index 00000000..6af6b441 --- /dev/null +++ b/x/qbftclient/proofapipb/proofapi.pb.go @@ -0,0 +1,1277 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc (unknown) +// source: proofapi/proofapi.proto + +package proofapipb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// The relay by tx request message +type RelayByTxRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The source chain identifier + SrcChain string `protobuf:"bytes,1,opt,name=src_chain,json=srcChain,proto3" json:"src_chain,omitempty"` + // The target chain identifier + DstChain string `protobuf:"bytes,2,opt,name=dst_chain,json=dstChain,proto3" json:"dst_chain,omitempty"` + // The identifiers for the IBC transactions to be relayed + // This is usually the transaction hash + SourceTxIds [][]byte `protobuf:"bytes,3,rep,name=source_tx_ids,json=sourceTxIds,proto3" json:"source_tx_ids,omitempty"` + // The identifiers for the IBC transactions on the target chain to be timed out + TimeoutTxIds [][]byte `protobuf:"bytes,4,rep,name=timeout_tx_ids,json=timeoutTxIds,proto3" json:"timeout_tx_ids,omitempty"` + // The identifier for the source client + // Used for event filtering + SrcClientId string `protobuf:"bytes,5,opt,name=src_client_id,json=srcClientId,proto3" json:"src_client_id,omitempty"` + // The identifier for the destination client + // Used for event filtering + DstClientId string `protobuf:"bytes,6,opt,name=dst_client_id,json=dstClientId,proto3" json:"dst_client_id,omitempty"` + // The optional source chain send packet sequences for recv packets + // Used for event filtering, no filtering if empty + SrcPacketSequences []uint64 `protobuf:"varint,7,rep,packed,name=src_packet_sequences,json=srcPacketSequences,proto3" json:"src_packet_sequences,omitempty"` + // The optional destination chain send packet sequences for acks and timeouts + // Used for event filtering, no filtering if empty + DstPacketSequences []uint64 `protobuf:"varint,8,rep,packed,name=dst_packet_sequences,json=dstPacketSequences,proto3" json:"dst_packet_sequences,omitempty"` +} + +func (x *RelayByTxRequest) Reset() { + *x = RelayByTxRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RelayByTxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayByTxRequest) ProtoMessage() {} + +func (x *RelayByTxRequest) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayByTxRequest.ProtoReflect.Descriptor instead. +func (*RelayByTxRequest) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{0} +} + +func (x *RelayByTxRequest) GetSrcChain() string { + if x != nil { + return x.SrcChain + } + return "" +} + +func (x *RelayByTxRequest) GetDstChain() string { + if x != nil { + return x.DstChain + } + return "" +} + +func (x *RelayByTxRequest) GetSourceTxIds() [][]byte { + if x != nil { + return x.SourceTxIds + } + return nil +} + +func (x *RelayByTxRequest) GetTimeoutTxIds() [][]byte { + if x != nil { + return x.TimeoutTxIds + } + return nil +} + +func (x *RelayByTxRequest) GetSrcClientId() string { + if x != nil { + return x.SrcClientId + } + return "" +} + +func (x *RelayByTxRequest) GetDstClientId() string { + if x != nil { + return x.DstClientId + } + return "" +} + +func (x *RelayByTxRequest) GetSrcPacketSequences() []uint64 { + if x != nil { + return x.SrcPacketSequences + } + return nil +} + +func (x *RelayByTxRequest) GetDstPacketSequences() []uint64 { + if x != nil { + return x.DstPacketSequences + } + return nil +} + +// Solana-specific update client transactions with chunking and ALT support +// Submission order: alt_create_tx -> alt_extend_txs (sequential) -> [chunk_txs (parallel)] -> assembly_tx -> cleanup_tx +type SolanaUpdateClient struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // All preparatory transactions: signatures + chunks (submitted in parallel with ALT extensions) + ChunkTxs [][]byte `protobuf:"bytes,1,rep,name=chunk_txs,json=chunkTxs,proto3" json:"chunk_txs,omitempty"` + // ALT creation transaction (must be submitted first) + AltCreateTx []byte `protobuf:"bytes,2,opt,name=alt_create_tx,json=altCreateTx,proto3" json:"alt_create_tx,omitempty"` + // ALT extension transactions (adds chunk accounts to ALT in batches, submit sequentially after creation) + AltExtendTxs [][]byte `protobuf:"bytes,3,rep,name=alt_extend_txs,json=altExtendTxs,proto3" json:"alt_extend_txs,omitempty"` + // Final assembly transaction (must be submitted last after ALT activation, uses ALT for compression) + AssemblyTx []byte `protobuf:"bytes,4,opt,name=assembly_tx,json=assemblyTx,proto3" json:"assembly_tx,omitempty"` + // Target height being updated to + TargetHeight uint64 `protobuf:"varint,5,opt,name=target_height,json=targetHeight,proto3" json:"target_height,omitempty"` + // Cleanup transaction (reclaims rent from chunks and signatures after assembly) + CleanupTx []byte `protobuf:"bytes,6,opt,name=cleanup_tx,json=cleanupTx,proto3" json:"cleanup_tx,omitempty"` +} + +func (x *SolanaUpdateClient) Reset() { + *x = SolanaUpdateClient{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SolanaUpdateClient) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolanaUpdateClient) ProtoMessage() {} + +func (x *SolanaUpdateClient) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SolanaUpdateClient.ProtoReflect.Descriptor instead. +func (*SolanaUpdateClient) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{1} +} + +func (x *SolanaUpdateClient) GetChunkTxs() [][]byte { + if x != nil { + return x.ChunkTxs + } + return nil +} + +func (x *SolanaUpdateClient) GetAltCreateTx() []byte { + if x != nil { + return x.AltCreateTx + } + return nil +} + +func (x *SolanaUpdateClient) GetAltExtendTxs() [][]byte { + if x != nil { + return x.AltExtendTxs + } + return nil +} + +func (x *SolanaUpdateClient) GetAssemblyTx() []byte { + if x != nil { + return x.AssemblyTx + } + return nil +} + +func (x *SolanaUpdateClient) GetTargetHeight() uint64 { + if x != nil { + return x.TargetHeight + } + return 0 +} + +func (x *SolanaUpdateClient) GetCleanupTx() []byte { + if x != nil { + return x.CleanupTx + } + return nil +} + +// Transactions for a single packet (chunks + final instruction + cleanup) +// Submission order: alt_create_tx (if present) -> alt_extend_txs (sequential) -> chunks (parallel) -> final_tx -> cleanup_tx +type SolanaPacketTxs struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Chunk upload transactions (can be submitted in parallel) + Chunks [][]byte `protobuf:"bytes,1,rep,name=chunks,proto3" json:"chunks,omitempty"` + // Final packet transaction (recv_packet, ack_packet, or timeout_packet) + FinalTx []byte `protobuf:"bytes,2,opt,name=final_tx,json=finalTx,proto3" json:"final_tx,omitempty"` + // Cleanup transaction (reclaims rent from chunks) + CleanupTx []byte `protobuf:"bytes,3,opt,name=cleanup_tx,json=cleanupTx,proto3" json:"cleanup_tx,omitempty"` + // ALT creation transaction (optional, must be submitted first if present) + AltCreateTx []byte `protobuf:"bytes,4,opt,name=alt_create_tx,json=altCreateTx,proto3" json:"alt_create_tx,omitempty"` + // ALT extension transactions (optional, adds accounts to ALT in batches, submit sequentially after creation) + AltExtendTxs [][]byte `protobuf:"bytes,5,rep,name=alt_extend_txs,json=altExtendTxs,proto3" json:"alt_extend_txs,omitempty"` + // GMP result PDA address (32 bytes) - present for ack/timeout packets on GMP port + // This PDA stores the acknowledgement or timeout result of a GMP call + // Seeds: ["gmp_result", source_client, sequence (little-endian u64)] + GmpResultPda []byte `protobuf:"bytes,6,opt,name=gmp_result_pda,json=gmpResultPda,proto3" json:"gmp_result_pda,omitempty"` + // IFT finalize_transfer transaction (optional) - present for ack/timeout packets from IFT + // Submit AFTER final_tx is confirmed and gmp_result_pda is initialized + // This reclaims tokens for the sender on timeout/failed ack, or closes PendingTransfer on success + IftFinalizeTransferTx []byte `protobuf:"bytes,7,opt,name=ift_finalize_transfer_tx,json=iftFinalizeTransferTx,proto3" json:"ift_finalize_transfer_tx,omitempty"` +} + +func (x *SolanaPacketTxs) Reset() { + *x = SolanaPacketTxs{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SolanaPacketTxs) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolanaPacketTxs) ProtoMessage() {} + +func (x *SolanaPacketTxs) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SolanaPacketTxs.ProtoReflect.Descriptor instead. +func (*SolanaPacketTxs) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{2} +} + +func (x *SolanaPacketTxs) GetChunks() [][]byte { + if x != nil { + return x.Chunks + } + return nil +} + +func (x *SolanaPacketTxs) GetFinalTx() []byte { + if x != nil { + return x.FinalTx + } + return nil +} + +func (x *SolanaPacketTxs) GetCleanupTx() []byte { + if x != nil { + return x.CleanupTx + } + return nil +} + +func (x *SolanaPacketTxs) GetAltCreateTx() []byte { + if x != nil { + return x.AltCreateTx + } + return nil +} + +func (x *SolanaPacketTxs) GetAltExtendTxs() [][]byte { + if x != nil { + return x.AltExtendTxs + } + return nil +} + +func (x *SolanaPacketTxs) GetGmpResultPda() []byte { + if x != nil { + return x.GmpResultPda + } + return nil +} + +func (x *SolanaPacketTxs) GetIftFinalizeTransferTx() []byte { + if x != nil { + return x.IftFinalizeTransferTx + } + return nil +} + +// Batch of packet transactions for relay operations +type SolanaRelayPacketBatch struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // List of packet transactions + Packets []*SolanaPacketTxs `protobuf:"bytes,1,rep,name=packets,proto3" json:"packets,omitempty"` + // Optional: update client transactions if client needs updating before relay + // Submission order: update_client first (if present), then packets + UpdateClient *SolanaUpdateClient `protobuf:"bytes,2,opt,name=update_client,json=updateClient,proto3" json:"update_client,omitempty"` +} + +func (x *SolanaRelayPacketBatch) Reset() { + *x = SolanaRelayPacketBatch{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SolanaRelayPacketBatch) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolanaRelayPacketBatch) ProtoMessage() {} + +func (x *SolanaRelayPacketBatch) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SolanaRelayPacketBatch.ProtoReflect.Descriptor instead. +func (*SolanaRelayPacketBatch) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{3} +} + +func (x *SolanaRelayPacketBatch) GetPackets() []*SolanaPacketTxs { + if x != nil { + return x.Packets + } + return nil +} + +func (x *SolanaRelayPacketBatch) GetUpdateClient() *SolanaUpdateClient { + if x != nil { + return x.UpdateClient + } + return nil +} + +// The relay by tx response message +type RelayByTxResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The multicall transaction to be submitted by caller + // For single transactions: contains the raw transaction bytes + // For multiple transactions (e.g. Solana chunks): contains serialized SolanaRelayPacketBatch + Tx []byte `protobuf:"bytes,1,opt,name=tx,proto3" json:"tx,omitempty"` + // The contract address to submit the transaction, if applicable + Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` +} + +func (x *RelayByTxResponse) Reset() { + *x = RelayByTxResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RelayByTxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayByTxResponse) ProtoMessage() {} + +func (x *RelayByTxResponse) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayByTxResponse.ProtoReflect.Descriptor instead. +func (*RelayByTxResponse) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{4} +} + +func (x *RelayByTxResponse) GetTx() []byte { + if x != nil { + return x.Tx + } + return nil +} + +func (x *RelayByTxResponse) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// The create client request message +type CreateClientRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The source chain identifier + SrcChain string `protobuf:"bytes,1,opt,name=src_chain,json=srcChain,proto3" json:"src_chain,omitempty"` + // The target chain identifier + DstChain string `protobuf:"bytes,2,opt,name=dst_chain,json=dstChain,proto3" json:"dst_chain,omitempty"` + // Optional genesis parameters + Parameters map[string]string `protobuf:"bytes,3,rep,name=parameters,proto3" json:"parameters,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *CreateClientRequest) Reset() { + *x = CreateClientRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateClientRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateClientRequest) ProtoMessage() {} + +func (x *CreateClientRequest) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateClientRequest.ProtoReflect.Descriptor instead. +func (*CreateClientRequest) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{5} +} + +func (x *CreateClientRequest) GetSrcChain() string { + if x != nil { + return x.SrcChain + } + return "" +} + +func (x *CreateClientRequest) GetDstChain() string { + if x != nil { + return x.DstChain + } + return "" +} + +func (x *CreateClientRequest) GetParameters() map[string]string { + if x != nil { + return x.Parameters + } + return nil +} + +// The create client response message +type CreateClientResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The transaction to be submitted by caller + Tx []byte `protobuf:"bytes,1,opt,name=tx,proto3" json:"tx,omitempty"` + // The contract address to submit the transaction, if applicable + Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` +} + +func (x *CreateClientResponse) Reset() { + *x = CreateClientResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateClientResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateClientResponse) ProtoMessage() {} + +func (x *CreateClientResponse) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateClientResponse.ProtoReflect.Descriptor instead. +func (*CreateClientResponse) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{6} +} + +func (x *CreateClientResponse) GetTx() []byte { + if x != nil { + return x.Tx + } + return nil +} + +func (x *CreateClientResponse) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// The update client request message +type UpdateClientRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The source chain identifier + SrcChain string `protobuf:"bytes,1,opt,name=src_chain,json=srcChain,proto3" json:"src_chain,omitempty"` + // The target chain identifier + DstChain string `protobuf:"bytes,2,opt,name=dst_chain,json=dstChain,proto3" json:"dst_chain,omitempty"` + // The identifier for the client on the destination chain + DstClientId string `protobuf:"bytes,3,opt,name=dst_client_id,json=dstClientId,proto3" json:"dst_client_id,omitempty"` +} + +func (x *UpdateClientRequest) Reset() { + *x = UpdateClientRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateClientRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateClientRequest) ProtoMessage() {} + +func (x *UpdateClientRequest) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateClientRequest.ProtoReflect.Descriptor instead. +func (*UpdateClientRequest) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{7} +} + +func (x *UpdateClientRequest) GetSrcChain() string { + if x != nil { + return x.SrcChain + } + return "" +} + +func (x *UpdateClientRequest) GetDstChain() string { + if x != nil { + return x.DstChain + } + return "" +} + +func (x *UpdateClientRequest) GetDstClientId() string { + if x != nil { + return x.DstClientId + } + return "" +} + +// The update client response message +type UpdateClientResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The transaction to be submitted by caller + // For single transactions: contains the raw transaction bytes + // For Solana: contains serialized SolanaUpdateClient + Tx []byte `protobuf:"bytes,1,opt,name=tx,proto3" json:"tx,omitempty"` + // The contract address to submit the transaction, if applicable + Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` +} + +func (x *UpdateClientResponse) Reset() { + *x = UpdateClientResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateClientResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateClientResponse) ProtoMessage() {} + +func (x *UpdateClientResponse) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateClientResponse.ProtoReflect.Descriptor instead. +func (*UpdateClientResponse) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{8} +} + +func (x *UpdateClientResponse) GetTx() []byte { + if x != nil { + return x.Tx + } + return nil +} + +func (x *UpdateClientResponse) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// Information request message +type InfoRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The source chain identifier + SrcChain string `protobuf:"bytes,1,opt,name=src_chain,json=srcChain,proto3" json:"src_chain,omitempty"` + // The target chain identifier + DstChain string `protobuf:"bytes,2,opt,name=dst_chain,json=dstChain,proto3" json:"dst_chain,omitempty"` +} + +func (x *InfoRequest) Reset() { + *x = InfoRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *InfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InfoRequest) ProtoMessage() {} + +func (x *InfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InfoRequest.ProtoReflect.Descriptor instead. +func (*InfoRequest) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{9} +} + +func (x *InfoRequest) GetSrcChain() string { + if x != nil { + return x.SrcChain + } + return "" +} + +func (x *InfoRequest) GetDstChain() string { + if x != nil { + return x.DstChain + } + return "" +} + +// Information response message +type InfoResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The target chain information + TargetChain *Chain `protobuf:"bytes,1,opt,name=target_chain,json=targetChain,proto3" json:"target_chain,omitempty"` + // The source chain information + SourceChain *Chain `protobuf:"bytes,2,opt,name=source_chain,json=sourceChain,proto3" json:"source_chain,omitempty"` + // Metadata for the module + Metadata map[string]string `protobuf:"bytes,3,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *InfoResponse) Reset() { + *x = InfoResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *InfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InfoResponse) ProtoMessage() {} + +func (x *InfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InfoResponse.ProtoReflect.Descriptor instead. +func (*InfoResponse) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{10} +} + +func (x *InfoResponse) GetTargetChain() *Chain { + if x != nil { + return x.TargetChain + } + return nil +} + +func (x *InfoResponse) GetSourceChain() *Chain { + if x != nil { + return x.SourceChain + } + return nil +} + +func (x *InfoResponse) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +// The chain definition +type Chain struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The chain id + ChainId string `protobuf:"bytes,1,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + // The ibc version + IbcVersion string `protobuf:"bytes,2,opt,name=ibc_version,json=ibcVersion,proto3" json:"ibc_version,omitempty"` + // The ibc contract address + IbcContract string `protobuf:"bytes,3,opt,name=ibc_contract,json=ibcContract,proto3" json:"ibc_contract,omitempty"` +} + +func (x *Chain) Reset() { + *x = Chain{} + if protoimpl.UnsafeEnabled { + mi := &file_proofapi_proofapi_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Chain) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Chain) ProtoMessage() {} + +func (x *Chain) ProtoReflect() protoreflect.Message { + mi := &file_proofapi_proofapi_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Chain.ProtoReflect.Descriptor instead. +func (*Chain) Descriptor() ([]byte, []int) { + return file_proofapi_proofapi_proto_rawDescGZIP(), []int{11} +} + +func (x *Chain) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +func (x *Chain) GetIbcVersion() string { + if x != nil { + return x.IbcVersion + } + return "" +} + +func (x *Chain) GetIbcContract() string { + if x != nil { + return x.IbcContract + } + return "" +} + +var File_proofapi_proofapi_proto protoreflect.FileDescriptor + +var file_proofapi_proofapi_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x6f, 0x66, + 0x61, 0x70, 0x69, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x70, 0x72, 0x6f, 0x6f, 0x66, + 0x61, 0x70, 0x69, 0x22, 0xc2, 0x02, 0x0a, 0x10, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x42, 0x79, 0x54, + 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x72, 0x63, 0x5f, + 0x63, 0x68, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x72, 0x63, + 0x43, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x61, + 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x73, 0x74, 0x43, 0x68, 0x61, + 0x69, 0x6e, 0x12, 0x22, 0x0a, 0x0d, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x74, 0x78, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x54, 0x78, 0x49, 0x64, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, + 0x74, 0x5f, 0x74, 0x78, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0c, + 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x54, 0x78, 0x49, 0x64, 0x73, 0x12, 0x22, 0x0a, 0x0d, + 0x73, 0x72, 0x63, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x72, 0x63, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, + 0x12, 0x22, 0x0a, 0x0d, 0x64, 0x73, 0x74, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, + 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x72, 0x63, 0x5f, 0x70, 0x61, 0x63, 0x6b, + 0x65, 0x74, 0x5f, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, + 0x28, 0x04, 0x52, 0x12, 0x73, 0x72, 0x63, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x65, 0x71, + 0x75, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x14, 0x64, 0x73, 0x74, 0x5f, 0x70, 0x61, + 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x08, + 0x20, 0x03, 0x28, 0x04, 0x52, 0x12, 0x64, 0x73, 0x74, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x53, + 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0xe0, 0x01, 0x0a, 0x12, 0x53, 0x6f, 0x6c, + 0x61, 0x6e, 0x61, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, + 0x1b, 0x0a, 0x09, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x74, 0x78, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0c, 0x52, 0x08, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x54, 0x78, 0x73, 0x12, 0x22, 0x0a, 0x0d, + 0x61, 0x6c, 0x74, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x78, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x61, 0x6c, 0x74, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x78, + 0x12, 0x24, 0x0a, 0x0e, 0x61, 0x6c, 0x74, 0x5f, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x5f, 0x74, + 0x78, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0c, 0x61, 0x6c, 0x74, 0x45, 0x78, 0x74, + 0x65, 0x6e, 0x64, 0x54, 0x78, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x73, 0x73, 0x65, 0x6d, 0x62, + 0x6c, 0x79, 0x5f, 0x74, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x61, 0x73, 0x73, + 0x65, 0x6d, 0x62, 0x6c, 0x79, 0x54, 0x78, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, + 0x74, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, + 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x5f, 0x74, 0x78, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x09, 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x54, 0x78, 0x22, 0x8c, 0x02, 0x0a, 0x0f, + 0x53, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x54, 0x78, 0x73, 0x12, + 0x16, 0x0a, 0x06, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, + 0x06, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x69, 0x6e, 0x61, 0x6c, + 0x5f, 0x74, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x66, 0x69, 0x6e, 0x61, 0x6c, + 0x54, 0x78, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x5f, 0x74, 0x78, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x54, + 0x78, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x6c, 0x74, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, + 0x74, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x61, 0x6c, 0x74, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x54, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x61, 0x6c, 0x74, 0x5f, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x78, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0c, 0x61, + 0x6c, 0x74, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x54, 0x78, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x67, + 0x6d, 0x70, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x70, 0x64, 0x61, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x67, 0x6d, 0x70, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x50, 0x64, + 0x61, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x66, 0x74, 0x5f, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, + 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x5f, 0x74, 0x78, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x15, 0x69, 0x66, 0x74, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, + 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x54, 0x78, 0x22, 0x90, 0x01, 0x0a, 0x16, 0x53, + 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x33, 0x0a, 0x07, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, + 0x69, 0x2e, 0x53, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x54, 0x78, + 0x73, 0x52, 0x07, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x75, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x6f, 0x6c, + 0x61, 0x6e, 0x61, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x0c, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x3d, 0x0a, + 0x11, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x42, 0x79, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, + 0x74, 0x78, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xdd, 0x01, 0x0a, + 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x72, 0x63, 0x5f, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x72, 0x63, 0x43, 0x68, 0x61, 0x69, + 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x73, 0x74, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x4d, + 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x1a, 0x3d, 0x0a, + 0x0f, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x40, 0x0a, 0x14, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x02, 0x74, 0x78, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0x73, + 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x72, 0x63, 0x5f, 0x63, 0x68, 0x61, + 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x72, 0x63, 0x43, 0x68, 0x61, + 0x69, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x73, 0x74, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x12, + 0x22, 0x0a, 0x0d, 0x64, 0x73, 0x74, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, + 0x74, 0x49, 0x64, 0x22, 0x40, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x74, + 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x74, 0x78, 0x12, 0x18, 0x0a, 0x07, 0x61, + 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0x47, 0x0a, 0x0b, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x72, 0x63, 0x5f, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x72, 0x63, 0x43, 0x68, 0x61, 0x69, + 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x73, 0x74, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x22, 0xf5, + 0x01, 0x0a, 0x0c, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x32, 0x0a, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, + 0x2e, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x52, 0x0b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x43, 0x68, + 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x63, 0x68, + 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x6f, + 0x66, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x52, 0x0b, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x70, 0x72, 0x6f, 0x6f, + 0x66, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x66, 0x0a, 0x05, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x12, + 0x19, 0x0a, 0x08, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x62, + 0x63, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x69, 0x62, 0x63, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x69, + 0x62, 0x63, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x69, 0x62, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x32, 0xac, + 0x02, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x41, 0x70, 0x69, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x42, 0x79, 0x54, 0x78, 0x12, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, + 0x42, 0x79, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, + 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x42, 0x79, 0x54, 0x78, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, + 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, + 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, + 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, + 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x15, + 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x61, 0x70, 0x69, + 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, + 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x65, 0x65, 0x72, + 0x73, 0x79, 0x73, 0x74, 0x2f, 0x63, 0x62, 0x64, 0x63, 0x2d, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x78, + 0x2f, 0x71, 0x62, 0x66, 0x74, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x6f, + 0x66, 0x61, 0x70, 0x69, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_proofapi_proofapi_proto_rawDescOnce sync.Once + file_proofapi_proofapi_proto_rawDescData = file_proofapi_proofapi_proto_rawDesc +) + +func file_proofapi_proofapi_proto_rawDescGZIP() []byte { + file_proofapi_proofapi_proto_rawDescOnce.Do(func() { + file_proofapi_proofapi_proto_rawDescData = protoimpl.X.CompressGZIP(file_proofapi_proofapi_proto_rawDescData) + }) + return file_proofapi_proofapi_proto_rawDescData +} + +var file_proofapi_proofapi_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_proofapi_proofapi_proto_goTypes = []interface{}{ + (*RelayByTxRequest)(nil), // 0: proofapi.RelayByTxRequest + (*SolanaUpdateClient)(nil), // 1: proofapi.SolanaUpdateClient + (*SolanaPacketTxs)(nil), // 2: proofapi.SolanaPacketTxs + (*SolanaRelayPacketBatch)(nil), // 3: proofapi.SolanaRelayPacketBatch + (*RelayByTxResponse)(nil), // 4: proofapi.RelayByTxResponse + (*CreateClientRequest)(nil), // 5: proofapi.CreateClientRequest + (*CreateClientResponse)(nil), // 6: proofapi.CreateClientResponse + (*UpdateClientRequest)(nil), // 7: proofapi.UpdateClientRequest + (*UpdateClientResponse)(nil), // 8: proofapi.UpdateClientResponse + (*InfoRequest)(nil), // 9: proofapi.InfoRequest + (*InfoResponse)(nil), // 10: proofapi.InfoResponse + (*Chain)(nil), // 11: proofapi.Chain + nil, // 12: proofapi.CreateClientRequest.ParametersEntry + nil, // 13: proofapi.InfoResponse.MetadataEntry +} +var file_proofapi_proofapi_proto_depIdxs = []int32{ + 2, // 0: proofapi.SolanaRelayPacketBatch.packets:type_name -> proofapi.SolanaPacketTxs + 1, // 1: proofapi.SolanaRelayPacketBatch.update_client:type_name -> proofapi.SolanaUpdateClient + 12, // 2: proofapi.CreateClientRequest.parameters:type_name -> proofapi.CreateClientRequest.ParametersEntry + 11, // 3: proofapi.InfoResponse.target_chain:type_name -> proofapi.Chain + 11, // 4: proofapi.InfoResponse.source_chain:type_name -> proofapi.Chain + 13, // 5: proofapi.InfoResponse.metadata:type_name -> proofapi.InfoResponse.MetadataEntry + 0, // 6: proofapi.ProofApiService.RelayByTx:input_type -> proofapi.RelayByTxRequest + 5, // 7: proofapi.ProofApiService.CreateClient:input_type -> proofapi.CreateClientRequest + 7, // 8: proofapi.ProofApiService.UpdateClient:input_type -> proofapi.UpdateClientRequest + 9, // 9: proofapi.ProofApiService.Info:input_type -> proofapi.InfoRequest + 4, // 10: proofapi.ProofApiService.RelayByTx:output_type -> proofapi.RelayByTxResponse + 6, // 11: proofapi.ProofApiService.CreateClient:output_type -> proofapi.CreateClientResponse + 8, // 12: proofapi.ProofApiService.UpdateClient:output_type -> proofapi.UpdateClientResponse + 10, // 13: proofapi.ProofApiService.Info:output_type -> proofapi.InfoResponse + 10, // [10:14] is the sub-list for method output_type + 6, // [6:10] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_proofapi_proofapi_proto_init() } +func file_proofapi_proofapi_proto_init() { + if File_proofapi_proofapi_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_proofapi_proofapi_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RelayByTxRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SolanaUpdateClient); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SolanaPacketTxs); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SolanaRelayPacketBatch); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RelayByTxResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateClientRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateClientResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateClientRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateClientResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*InfoRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*InfoResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proofapi_proofapi_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Chain); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_proofapi_proofapi_proto_rawDesc, + NumEnums: 0, + NumMessages: 14, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proofapi_proofapi_proto_goTypes, + DependencyIndexes: file_proofapi_proofapi_proto_depIdxs, + MessageInfos: file_proofapi_proofapi_proto_msgTypes, + }.Build() + File_proofapi_proofapi_proto = out.File + file_proofapi_proofapi_proto_rawDesc = nil + file_proofapi_proofapi_proto_goTypes = nil + file_proofapi_proofapi_proto_depIdxs = nil +} From f22924348a07476663eda20daa9fd134278a1889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 12:39:23 +0200 Subject: [PATCH 22/61] fix(corridord): receipt check hashed the wrong thing, and failed open ICS26Router.getCommitment takes the keccak256 of the receipt path, not the path itself. Passing the raw bytes returned zero for every packet, so the already-delivered check always answered no and the daemon redelivered on every tick. Nothing bad happened only because IBC's own replay protection rejected the duplicates, and relying on that is not a design. The error path was wrong in the more dangerous direction too: an unreachable node was read as not-yet-delivered, which is the same redelivery loop triggered by a network blip. It now fails closed. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/chains.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index 8fe28f4b..0b6195f3 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "time" + + "github.com/ethereum/go-ethereum/crypto" ) // Both clients shell out to cast/cbdcd for anything that signs or proves. @@ -87,7 +89,9 @@ func (b *besuClient) packetReceived(ctx context.Context, router, clientID string out, err := run(ctx, "cast", "call", "-r", b.rpc, router, "getCommitment(bytes32)(bytes32)", receiptCommitmentKey(clientID, seq)) if err != nil { - return false, nil + // Fail CLOSED: an unreachable node must not be read as "not yet + // delivered", which would redeliver on every tick. + return false, fmt.Errorf("receipt check failed, refusing to assume: %w", err) } return !strings.Contains(out, "0x0000000000000000000000000000000000000000000000000000000000000000"), nil } @@ -235,8 +239,13 @@ func receiptPath(clientID string, seq uint64) []byte { 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 "0x" + hex.EncodeToString(receiptPath(clientID, seq)) + return crypto.Keccak256Hash(receiptPath(clientID, seq)).Hex() } // solidityTuple converts a protobuf packet into the tuple cast needs, using the From 7c15d07dcf600c0eb468de53794a6f85a5dae1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 12:53:11 +0200 Subject: [PATCH 23/61] fix(corridor): two defects the security review found in my own work The receipt check's "fail closed" comment was aspirational. Both call sites did `if recvd, _ :=`, discarding the error, so an RPC hiccup produced the zero value false -- read as "not delivered" -- and the redelivery loop the fix was supposed to remove. Now the error propagates and the tick aborts. Worse, the attestor's package doc claimed the freeze guard survives a restart because it "re-derives from the chain, which is deterministic". No such code exists. `seen` is in memory and starts empty, so the guard protects nothing in the one case that matters: local-node.sh does `rm -rf $HOMEDIR`, so re-genesis is the routine workflow, heights repeat with new timestamps, and a restarted sidecar will sign the new one against a light client still holding the old -- freezing it permanently and stranding the escrow. The comment now says what the code does, names CometBFT's per-height determinism as what actually protects the rig today, and records that the real fix is to bind attestations to a chain identity rather than to persist the map. Found by a defensive review of the running rig. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/main.go | 14 ++++++++++++-- cmd/qbftattestor/main.go | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index 3d1c0a0d..aeb2fb54 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -140,7 +140,13 @@ func (d *driver) outbound(ctx context.Context) error { if d.doneOut[seq] { continue } - if recvd, _ := d.besu.packetReceived(ctx, d.cfg.router, d.cfg.besuCli, seq); recvd { + 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 } @@ -191,7 +197,11 @@ func (d *driver) inbound(ctx context.Context) error { if d.doneIn[p.sequence] { continue } - if got, _ := d.cbdc.packetReceived(ctx, d.cfg.cbdcCli, p.sequence); got { + 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 } diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go index f73b0a79..b2fceaf2 100644 --- a/cmd/qbftattestor/main.go +++ b/cmd/qbftattestor/main.go @@ -22,9 +22,22 @@ // FREEZE SAFETY // // Signing two different timestamps for one height freezes the client -// permanently, with no unfreeze. This process therefore remembers every height -// it has attested and refuses to sign a second, different timestamp for it -- -// even across a restart it re-derives from the chain, which is deterministic. +// permanently, with no unfreeze. This process remembers every height it has +// attested and refuses to sign a second, different timestamp for it. +// +// 🔴 THE GUARD IS NOT SUFFICIENT, AND THIS COMMENT PREVIOUSLY CLAIMED OTHERWISE. +// `seen` is in memory only. It starts EMPTY on every restart -- there is no +// re-derivation from the chain, despite an earlier version of this comment +// saying so. The map therefore protects nothing in the one scenario that +// matters: cbdc-node is re-genesised (local-node.sh does `rm -rf $HOMEDIR`, so +// this is the routine workflow), heights repeat with new timestamps, and a +// restarted sidecar signs the new one against a light client that still holds +// the old. That freezes the client permanently and strands the escrow. +// +// What actually protects the rig today is CometBFT's per-height determinism, +// not this map -- and determinism is exactly what a re-genesis breaks. The real +// fix is to bind attestations to a chain identity (chain-id or genesis hash) so +// a re-genesis cannot collide, rather than to persist the map. package main import ( From f4b2542a5c1d7a317bf4fb922ebd8ca53dc25614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 12:57:59 +0200 Subject: [PATCH 24/61] fix(corridord): temp-file race on the signing path, and an unfiltered log scan F9: the unsigned tx was written to a predictable /tmp path and then handed to `cbdcd tx sign --from alice`. A key gets applied to whatever sits at that path, so a local user could pre-create a symlink or swap the file between write and sign. Now a per-invocation 0700 dir via os.MkdirTemp, removed on return. F10: the SendPacket filter matched only the router address and topic0, then read Topics[2] as the sequence without checking Topics[1]. A string indexed parameter is stored as the keccak256 of the string, so logs from any other client on the same router were relayed as if they were ours. Now matched against keccak256(clientID), case-insensitively since eth_getLogs returns lowercase hex. The same query also rescanned from block 0 on every tick. It now starts from a cursor that advances only to blocks whose logs were actually returned -- never to the chain head, which could name a block the query did not cover and silently skip packets. The last log-bearing block is re-scanned inclusively, which is harmless because the receipt check drops duplicates, and an empty result leaves the cursor untouched. Residual: a reorg deeper than the last log-bearing block would not be re-fetched. QBFT finalises immediately so this cannot arise here; a reorg-capable chain would need a confirmation-depth lag. The cursor is in-memory, so a restart rescans from 0 -- consistent with this daemon's stated no-crash-resume scope. Fixes found by a defensive review of the running rig. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/chains.go | 49 ++++++++++++++++++++++++++++++++++------- cmd/corridord/main.go | 2 +- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index 0b6195f3..b81fb4b8 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -18,7 +20,14 @@ import ( // 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 } +type besuClient struct { + rpc string + + // lastScanned is the highest block a SendPacket log has been seen in. + // Subsequent eth_getLogs queries start here instead of 0, so the scan + // window stops growing with chain length. + lastScanned uint64 +} type sendPacket struct { sequence uint64 @@ -52,26 +61,41 @@ func (b *besuClient) call(ctx context.Context, method string, params any) (json. return out.Result, nil } -// sendPackets lists SendPacket events emitted by the router. -func (b *besuClient) sendPackets(ctx context.Context, router string) ([]sendPacket, error) { +// 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() res, err := b.call(ctx, "eth_getLogs", []any{map[string]any{ - "address": router, "fromBlock": "0x0", "toBlock": "latest", "topics": []any{topic}, + "address": router, "fromBlock": fmt.Sprintf("0x%x", b.lastScanned), "toBlock": "latest", "topics": []any{topic}, }}) if err != nil { return nil, err } var logs []struct { - Topics []string `json:"topics"` - TxHash string `json:"transactionHash"` + Topics []string `json:"topics"` + TxHash string `json:"transactionHash"` + BlockNumber string `json:"blockNumber"` } if err := json.Unmarshal(res, &logs); err != nil { return nil, err } out := make([]sendPacket, 0, len(logs)) for _, l := range logs { - if len(l.Topics) < 3 { + // Advance the scan cursor only to blocks whose logs we actually + // received, never to the chain head: eth_blockNumber could name a + // block this query did not cover, silently skipping packets. The + // last log-bearing block is re-scanned next tick (fromBlock is + // inclusive), which is harmless -- the caller's receipt check drops + // duplicates -- and an empty result leaves the cursor untouched. + if bn, err := strconv.ParseUint(strings.TrimPrefix(l.BlockNumber, "0x"), 16, 64); err == nil && bn > b.lastScanned { + b.lastScanned = bn + } + if len(l.Topics) < 3 || !strings.EqualFold(l.Topics[1], clientTopic) { continue } seq, err := strconv.ParseUint(strings.TrimPrefix(l.Topics[2], "0x"), 16, 64) @@ -288,7 +312,16 @@ func (d *driver) relayInbound(ctx context.Context, p sendPacket) error { _ = json.Unmarshal(head, &hexHead) target, _ := strconv.ParseUint(strings.TrimPrefix(hexHead, "0x"), 16, 64) - unsigned := fmt.Sprintf("/tmp/corridord-recv-%d.json", p.sequence) + // 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("recv-%d.json", p.sequence)) if _, err := run(ctx, "go", "run", "./cmd/qbftrelay", "-besu-rpc", d.cfg.besuRPC, "-contract", d.cfg.router, "-client-id", d.cfg.cbdcCli, "-packet-hex", pkt, "-trusted-height", strconv.FormatUint(trusted, 10), diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index aeb2fb54..c383a449 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -189,7 +189,7 @@ func (d *driver) outbound(ctx context.Context) error { // 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) + packets, err := d.besu.sendPackets(ctx, d.cfg.router, d.cfg.besuCli) if err != nil { return err } From 691f5040369145b71a7ce0ce97423c065e039269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 13:07:11 +0200 Subject: [PATCH 25/61] chore: F12 cleanups, with the seen-map trade-off made explicit Four items from the defensive review. The `seen` map grew by one entry per attested height forever. Bounding it is not free, though, because that map IS the freeze guard: an evicted height is no longer guarded, so a later conflicting attestation for it would be signed and would freeze the client permanently. The eviction is therefore capped at 100k entries, logs loudly when it trims, and records in a comment that this is only acceptable because the guard is already insufficient across restarts, and that the real fix -- binding attestations to a chain identity -- removes the need for the map rather than merely bounding it. corridord read attestor error bodies with a single Read into a 512-byte buffer, so a refusal explaining why the attestor declined could be truncated to nothing useful. Now io.ReadAll behind an 8 KiB limit, so the message survives without letting a misbehaving endpoint stream forever. Also removed: an unused context import and its blank-assignment placeholder in the attestor, and three ABI type variables in the signing package that were declared and never used. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/main.go | 10 +++++-- cmd/qbftattestor/grpc.go | 1 + cmd/qbftattestor/main.go | 42 ++++++++++++++++++++++++++-- x/qbftclient/attestor/attestation.go | 4 --- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index c383a449..a490e7de 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -28,6 +28,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "log" "net/http" "os" @@ -226,9 +227,12 @@ func (d *driver) askAttestor(ctx context.Context, path string, body map[string]a } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - msg := make([]byte, 512) - n, _ := resp.Body.Read(msg) - return nil, fmt.Errorf("attestor refused (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg[:n]))) + // 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"` diff --git a/cmd/qbftattestor/grpc.go b/cmd/qbftattestor/grpc.go index fc2e4573..fec2fdd5 100644 --- a/cmd/qbftattestor/grpc.go +++ b/cmd/qbftattestor/grpc.go @@ -49,6 +49,7 @@ func (a *aggregatorServer) GetAttestations(ctx context.Context, req *pb.GetAttes height, prev, ts) } a.s.seen[height] = ts + a.s.evictLocked() a.s.mu.Unlock() stateData, err := attestor.EncodeState(height, ts) diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go index b2fceaf2..bf69146f 100644 --- a/cmd/qbftattestor/main.go +++ b/cmd/qbftattestor/main.go @@ -41,7 +41,6 @@ package main import ( - "context" "crypto/ecdsa" "encoding/hex" "encoding/json" @@ -65,8 +64,24 @@ type server struct { mu sync.Mutex seen map[uint64]uint64 // height -> timestamp already attested + // lowWater is the oldest height still in `seen`. Everything below it has + // been evicted and is no longer guarded. + lowWater uint64 } +// seenLimit bounds `seen`, which would otherwise grow by one entry per attested +// height forever. +// +// ⚠️ Eviction is not free: an evicted height is no longer guarded, so a second, +// conflicting attestation for it would be signed and would freeze the client. +// That is acceptable only because the guard is already known to be insufficient +// (it is in-memory and empty after a restart -- see the package doc), and +// because heights are attested in roughly increasing order, so evicted heights +// are ones the corridor has long moved past. The real fix is to bind +// attestations to a chain identity so a repeated height cannot collide at all, +// which makes this map unnecessary rather than merely bounded. +const seenLimit = 100_000 + func main() { var ( rpc = flag.String("cbdc-rpc", "http://127.0.0.1:26657", "cbdc-node Tendermint RPC") @@ -147,6 +162,7 @@ func (s *server) attestState(w http.ResponseWriter, r *http.Request) { return } s.seen[req.Height] = ts + s.evictLocked() s.mu.Unlock() proof, err := attestor.StateProof(s.key, req.Height, ts) @@ -206,9 +222,29 @@ func trim0x(s string) string { return s } +// evictLocked drops the oldest guarded heights once the map exceeds seenLimit. +// Caller must hold s.mu. +func (s *server) evictLocked() { + if len(s.seen) <= seenLimit { + return + } + for h := range s.seen { + if s.lowWater == 0 || h < s.lowWater { + s.lowWater = h + } + } + for h := range s.seen { + if len(s.seen) <= seenLimit { + break + } + if h <= s.lowWater { + delete(s.seen, h) + } + } + log.Printf("seen map trimmed to %d entries; heights at or below %d are NO LONGER freeze-guarded", len(s.seen), s.lowWater) +} + // keccakPath hashes a full ICS-24 path for PacketCompact.Path. func keccakPath(path []byte) [32]byte { return common.BytesToHash(crypto.Keccak256(path)) } - -var _ = context.Background diff --git a/x/qbftclient/attestor/attestation.go b/x/qbftclient/attestor/attestation.go index 02d6245d..17999e4c 100644 --- a/x/qbftclient/attestor/attestation.go +++ b/x/qbftclient/attestor/attestation.go @@ -46,10 +46,6 @@ type PacketCompact struct { } var ( - uint64Ty, _ = abi.NewType("uint64", "", nil) - bytesTy, _ = abi.NewType("bytes", "", nil) - bytesArrTy, _ = abi.NewType("bytes[]", "", nil) - stateTupleTy, _ = abi.NewType("tuple", "", []abi.ArgumentMarshaling{ {Name: "height", Type: "uint64"}, {Name: "timestamp", Type: "uint64"}, From a74e1a995a8d4e0b4b32f0b9d7b2be710ab89533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 13:45:01 +0200 Subject: [PATCH 26/61] fix(corridor): paginate tx_search, and bind the attestor to one target F6: sendEvents asked for per_page=100 and never set page or read total_count, so once cbdc-node holds more than 100 matching txs the enumeration truncated silently -- packets past the cap would never be relayed and the funds would sit escrowed with nothing logged. It now pages through total_count, terminating on a short or empty page as well so a server misreporting the total cannot spin it forever. pendingSequences was also a lie: it returns every sequence ever sent and filters nothing, and on this rig commitments never clear because acks are not relayed. Renamed to allSentSequences with the reason recorded, so the monotonic growth reads as a known scope limit rather than a filtering bug. The relaying logic is unchanged -- the caller's receipt check is what separates delivered from undelivered. F3 cannot be fixed at this pin and the commit should say so plainly. The signed payload is {height, timestamp} with no chain id, client id or contract address, so a signature verifies against ANY light client trusting that key. Widening the struct is not available: the verifier abi.decodes the signed bytes into its own structs, so extra fields would break verification for every deployed client, and forking the contract would forfeit the only external audit left in the system. A test now pins the encoding at exactly two words so a well-meaning "fix" trips a test rather than the rig. What is possible is process-level binding, so the attestor now requires -cbdc-chain-id, -light-client and -besu-chain-id, and refuses to start when the node's reported chain id does not match its configuration. Verified: it exits rather than sign for a chain it was not configured for. The residual is documented rather than papered over. This catches a wrong RPC or a re-genesis under a NEW chain id; a re-genesis reusing the same id still passes and still repeats heights with fresh timestamps, which is the F2 freeze path. Domain separation needs an upstream struct change or a different client. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/chains.go | 74 +++++++++++++++-------- cmd/corridord/main.go | 2 +- cmd/qbftattestor/cbdc.go | 20 ++++++ cmd/qbftattestor/main.go | 26 ++++++++ x/qbftclient/attestor/attestation.go | 19 ++++++ x/qbftclient/attestor/attestation_test.go | 11 ++++ 6 files changed, 127 insertions(+), 25 deletions(-) diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index b81fb4b8..ac9e60b9 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -175,42 +175,68 @@ type txSearch struct { } `json:"events"` } `json:"tx_result"` } `json:"txs"` + // total_count is a STRING in CometBFT's JSON, not a number. + TotalCount string `json:"total_count"` } `json:"result"` } func (c *cbdcRPC) sendEvents(ctx context.Context, clientID string) (map[uint64]string, error) { - q := url.Values{} - q.Set("query", fmt.Sprintf("\"send_packet.packet_source_client='%s'\"", clientID)) - q.Set("per_page", "100") - var out txSearch - if err := c.get(ctx, "/tx_search?"+q.Encode(), &out); err != nil { - return nil, err - } + // 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. + const perPage = 100 res := map[uint64]string{} - for _, tx := range out.Result.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 + fetched := 0 + for page := 1; ; page++ { + q := url.Values{} + q.Set("query", fmt.Sprintf("\"send_packet.packet_source_client='%s'\"", clientID)) + 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 + } + for _, tx := range out.Result.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 } } - if seq != 0 && pkt != "" { - res[seq] = pkt - } + } + fetched += len(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 fetched >= total || len(out.Result.Txs) < perPage { + break } } return res, nil } -func (c *cbdcRPC) pendingSequences(ctx context.Context, clientID string) ([]uint64, error) { +// allSentSequences returns EVERY sequence this client has ever sent, not a +// "pending" set: nothing here filters against outstanding commitments, and on +// this rig acks are never relayed so commitments never clear anyway. 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 diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index a490e7de..3c208048 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -133,7 +133,7 @@ func (d *driver) tick(ctx context.Context) error { // 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.pendingSequences(ctx, d.cfg.cbdcCli) + seqs, err := d.cbdc.allSentSequences(ctx, d.cfg.cbdcCli) if err != nil { return err } diff --git a/cmd/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go index 8b0d4ff1..a07c1466 100644 --- a/cmd/qbftattestor/cbdc.go +++ b/cmd/qbftattestor/cbdc.go @@ -102,6 +102,26 @@ func (c *cbdcClient) commitment(ctx context.Context, path []byte, height uint64) return zero, 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 { diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go index bf69146f..996a7c93 100644 --- a/cmd/qbftattestor/main.go +++ b/cmd/qbftattestor/main.go @@ -41,6 +41,7 @@ package main import ( + "context" "crypto/ecdsa" "encoding/hex" "encoding/json" @@ -89,6 +90,14 @@ func main() { 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)") ) flag.Parse() @@ -102,6 +111,9 @@ func main() { 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)") + } key, err := crypto.HexToECDSA(trim0x(k)) if err != nil { log.Fatalf("bad key: %v", err) @@ -114,8 +126,22 @@ func main() { seen: map[uint64]uint64{}, } + // 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) + } + log.Printf("attestor %s", crypto.PubkeyToAddress(key.PublicKey)) 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 != "" { diff --git a/x/qbftclient/attestor/attestation.go b/x/qbftclient/attestor/attestation.go index 17999e4c..1910a9cd 100644 --- a/x/qbftclient/attestor/attestation.go +++ b/x/qbftclient/attestor/attestation.go @@ -19,6 +19,25 @@ // the digest as a plain sha256 of the data with no domain tag, and the packet path // hash as sha256. The deployed contract uses the tag byte above and keccak256 for // the path. The contract is authoritative; the design doc is not. +// +// SECURITY: THE SIGNED PAYLOAD CARRIES NO DOMAIN SEPARATION. +// +// StateAttestation is {height, timestamp} and PacketAttestation is {height, +// packets} -- no chain id, no client id, no verifying contract address. A +// signature is therefore valid against ANY AttestationLightClient whose +// attestor set contains the key: reuse the key on a second corridor, or against +// a redeployed client, and attestations cross-replay. Worse, because two chains +// disagree on the timestamp at a given height number, a cross-replayed state +// attestation is itself a permanent-freeze trigger. +// +// This is a property of the deployed contract, not of this code: the verifier +// abi.decodes the signed bytes into its OWN structs, so widening them here just +// breaks on-chain verification (see TestEncodeState_CarriesNoDomainSeparation). +// cmd/qbftattestor mitigates at the process level -- it binds one key to one +// configured target and refuses to start if the node's reported chain id does +// not match its configuration -- but that is a mitigation, not a fix. The real +// fix is an upstream struct change (or a different light client), and until +// then every (key, client) pair must be unique per corridor and per deployment. package attestor import ( diff --git a/x/qbftclient/attestor/attestation_test.go b/x/qbftclient/attestor/attestation_test.go index e02711d2..e1ec1362 100644 --- a/x/qbftclient/attestor/attestation_test.go +++ b/x/qbftclient/attestor/attestation_test.go @@ -27,6 +27,17 @@ func TestEncodeState_MatchesCast(t *testing.T) { require.Equal(t, goldenStateABI, hex.EncodeToString(got)) } +// The signed state payload is (height, timestamp) and NOTHING else -- no chain +// id, no client id, no contract address (see the package doc). That is the +// deployed contract's struct, so it cannot be widened from this side: a +// well-meaning "fix" that adds a domain field would break on-chain verification +// for every deployed client. Pinned so that change trips a test, not the rig. +func TestEncodeState_CarriesNoDomainSeparation(t *testing.T) { + got, err := EncodeState(goldenHeight, goldenTimestamp) + require.NoError(t, err) + require.Len(t, got, 64, "exactly two abi words (height, timestamp); widening breaks the deployed contract") +} + // The tag is prepended to the INNER HASH, not to the data. Both orderings // produce a 32-byte digest and only one of them verifies, so this is pinned // rather than left to reading. From d21ab160399a94284fbc36345b5b51ca4d307ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 14:18:09 +0200 Subject: [PATCH 27/61] fix(qbftattestor): make the freeze guard durable and detect a re-genesis F2, the last high-severity defect. The guard against signing two different timestamps for one height -- which freezes the spoke's light client permanently and strands the escrow -- was an in-memory map that started empty on every restart. It protected nothing in the one scenario that matters, because local-node.sh does `rm -rf $HOMEDIR`, so re-genesis is the routine workflow: heights restart from 1 with new timestamps while the deployed client still holds the old ones. Two process-side mechanisms, because the payload cannot carry a chain identity: the contract abi.decodes the signed bytes into its own struct, so widening them breaks verification for every deployed client. The record is now an append-only seen.jsonl, fsynced BEFORE the signature is returned -- never after, or a crash between signing and writing reopens the hole -- and replayed at startup. A torn trailing record means a crash mid-append, so nothing was ever signed, and is truncated; a newline- terminated conflict is fatal. The block-1 hash is pinned on first run. It changes when a chain is re-genesised even under the same chain id, which is exactly what the existing -cbdc-chain-id check could not catch. On mismatch the process refuses to start and says why. -reset-state is the escape hatch for a genuine redeploy and logs loudly before wiping. Eviction now refuses rather than un-guards: below the low-water mark the guard declines to sign instead of silently losing the guarantee. The log itself is never trimmed -- 30 bytes per height is cheap against a permanently frozen corridor. Verified live: with the recorded block-1 hash altered to simulate a re-genesis, the sidecar refuses to start and names the consequence. Residual, none of it closed by this change: -reset-state is loud but unverified, since this process cannot confirm the light client was actually redeployed; deleting the state dir is indistinguishable from a first run; cross-replay remains possible because the payload has no domain separation; this guards one key, so unguarded co-signers at m-of-n freeze it anyway; and a Byzantine RPC serving two timestamps for one height wins on its first answer. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/qbftattestor/cbdc.go | 22 +++ cmd/qbftattestor/grpc.go | 14 +- cmd/qbftattestor/main.go | 137 ++++++++++--------- cmd/qbftattestor/state.go | 237 +++++++++++++++++++++++++++++++++ cmd/qbftattestor/state_test.go | 109 +++++++++++++++ 5 files changed, 448 insertions(+), 71 deletions(-) create mode 100644 cmd/qbftattestor/state.go create mode 100644 cmd/qbftattestor/state_test.go diff --git a/cmd/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go index a07c1466..d12375a5 100644 --- a/cmd/qbftattestor/cbdc.go +++ b/cmd/qbftattestor/cbdc.go @@ -102,6 +102,28 @@ func (c *cbdcClient) commitment(ctx context.Context, path []byte, height uint64) return zero, 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. diff --git a/cmd/qbftattestor/grpc.go b/cmd/qbftattestor/grpc.go index fec2fdd5..eafece33 100644 --- a/cmd/qbftattestor/grpc.go +++ b/cmd/qbftattestor/grpc.go @@ -40,17 +40,11 @@ func (a *aggregatorServer) GetAttestations(ctx context.Context, req *pb.GetAttes } // Same freeze guard as the HTTP path. Two different timestamps for one - // height is terminal, so refuse rather than let a caller induce it. - a.s.mu.Lock() - if prev, ok := a.s.seen[height]; ok && prev != ts { - a.s.mu.Unlock() - return nil, fmt.Errorf( - "refusing: already attested height %d as %d, now reading %d -- signing both would freeze the client permanently", - height, prev, ts) + // 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 } - a.s.seen[height] = ts - a.s.evictLocked() - a.s.mu.Unlock() stateData, err := attestor.EncodeState(height, ts) if err != nil { diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go index 996a7c93..4b38416e 100644 --- a/cmd/qbftattestor/main.go +++ b/cmd/qbftattestor/main.go @@ -22,22 +22,27 @@ // FREEZE SAFETY // // Signing two different timestamps for one height freezes the client -// permanently, with no unfreeze. This process remembers every height it has -// attested and refuses to sign a second, different timestamp for it. +// 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: // -// 🔴 THE GUARD IS NOT SUFFICIENT, AND THIS COMMENT PREVIOUSLY CLAIMED OTHERWISE. -// `seen` is in memory only. It starts EMPTY on every restart -- there is no -// re-derivation from the chain, despite an earlier version of this comment -// saying so. The map therefore protects nothing in the one scenario that -// matters: cbdc-node is re-genesised (local-node.sh does `rm -rf $HOMEDIR`, so -// this is the routine workflow), heights repeat with new timestamps, and a -// restarted sidecar signs the new one against a light client that still holds -// the old. That freezes the client permanently and strands the escrow. +// 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. // -// What actually protects the rig today is CometBFT's per-height determinism, -// not this map -- and determinism is exactly what a re-genesis breaks. The real -// fix is to bind attestations to a chain identity (chain-id or genesis hash) so -// a re-genesis cannot collide, rather than to persist the map. +// 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 ( @@ -45,6 +50,7 @@ import ( "crypto/ecdsa" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "log" @@ -65,22 +71,26 @@ type server struct { mu sync.Mutex seen map[uint64]uint64 // height -> timestamp already attested - // lowWater is the oldest height still in `seen`. Everything below it has - // been evicted and is no longer guarded. + // 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 `seen`, which would otherwise grow by one entry per attested -// height forever. +// seenLimit bounds the in-memory `seen` map, which would otherwise grow by one +// entry per attested height forever. // -// ⚠️ Eviction is not free: an evicted height is no longer guarded, so a second, -// conflicting attestation for it would be signed and would freeze the client. -// That is acceptable only because the guard is already known to be insufficient -// (it is in-memory and empty after a restart -- see the package doc), and -// because heights are attested in roughly increasing order, so evicted heights -// are ones the corridor has long moved past. The real fix is to bind -// attestations to a chain identity so a repeated height cannot collide at all, -// which makes this map unnecessary rather than merely bounded. +// 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() { @@ -98,6 +108,8 @@ func main() { 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() @@ -114,16 +126,20 @@ func main() { 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) } s := &server{ - key: key, - chain: &cbdcClient{rpc: *rpc}, - client: *clientID, - seen: map[uint64]uint64{}, + key: key, + 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 @@ -139,6 +155,29 @@ func main() { 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", crypto.PubkeyToAddress(key.PublicKey)) 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) @@ -179,17 +218,15 @@ func (s *server) attestState(w http.ResponseWriter, r *http.Request) { // 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. - s.mu.Lock() - if prev, ok := s.seen[req.Height]; ok && prev != ts { - s.mu.Unlock() - http.Error(w, fmt.Sprintf( - "REFUSING: already attested height %d as %d, now reading %d -- signing both would freeze the client permanently", - req.Height, prev, ts), http.StatusConflict) + // 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 } - s.seen[req.Height] = ts - s.evictLocked() - s.mu.Unlock() proof, err := attestor.StateProof(s.key, req.Height, ts) if err != nil { @@ -248,28 +285,6 @@ func trim0x(s string) string { return s } -// evictLocked drops the oldest guarded heights once the map exceeds seenLimit. -// Caller must hold s.mu. -func (s *server) evictLocked() { - if len(s.seen) <= seenLimit { - return - } - for h := range s.seen { - if s.lowWater == 0 || h < s.lowWater { - s.lowWater = h - } - } - for h := range s.seen { - if len(s.seen) <= seenLimit { - break - } - if h <= s.lowWater { - delete(s.seen, h) - } - } - log.Printf("seen map trimmed to %d entries; heights at or below %d are NO LONGER freeze-guarded", len(s.seen), s.lowWater) -} - // 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 00000000..4af3b58c --- /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 amortises 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 00000000..8f68b8a6 --- /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, cap int) *server { + t.Helper() + s := &server{seen: map[uint64]uint64{}, seenCap: cap} + 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")) +} From 1a04dbe5d1bd58f3def9429413174ef270deffe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 14:37:35 +0200 Subject: [PATCH 28/61] fix(corridor): give the Besu escrow a refund path F1. A failed cbdc-node -> Besu transfer could never return its funds, for two independent reasons. Timeouts were cryptographically impossible: ICS26Router.timeoutPacket needs verifyNonMembership, which the attestation client accepts only for an attested {keccak256(receiptPath), bytes32(0)} pair, and the sidecar had no way to produce one -- an absent commitment was treated as an error and refused. And acknowledgements were never relayed at all, so send commitments never cleared. The absence path is the most dangerous code here: signing "this packet was never received" about one that WAS received releases escrow that must not be released. It proves absence from the sidecar's own view and refuses anything ambiguous. The trap that makes this non-trivial, found while implementing it: the SDK's IAVL store answers a query for a pruned or nonexistent version with code 0 and an empty value -- byte-identical to genuine absence, differing only in Log. The countermeasure is prove=1, which makes rootmulti hard-error on a missing version instead of returning empty success, and acceptance now requires all of code 0, the response height echoing the request, proof ops present, and an empty value. A floating height is refused before the node is consulted. Absence is deliberately NOT exposed over gRPC: upstream's request message cannot express non-membership intent, and inferring it from an empty read is precisely the failure mode the HTTP path was hardened against. Ack relaying now runs in both directions -- Besu acks to cbdc-node via qbftrelay's new -as-ack, cbdc acks to Besu via ICS26Router.ackPacket, each gated on the counterparty commitment still being set. corridord keeps its holds-no-keys property: attestation via the sidecar, signing via cbdcd. Timeout submission stays operator-driven rather than automated, because the daemon cannot evaluate the timeout safety condition without parsing packet bodies out of Besu logs. The contract enforces it either way. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/chains.go | 307 ++++++++++++++++++---- cmd/corridord/main.go | 157 ++++++++++- cmd/qbftattestor/cbdc.go | 62 +++++ cmd/qbftattestor/cbdc_test.go | 222 ++++++++++++++++ cmd/qbftattestor/grpc.go | 14 +- cmd/qbftattestor/main.go | 123 +++++++++ cmd/qbftrelay/main.go | 44 +++- x/qbftclient/attestor/attestation.go | 50 +++- x/qbftclient/attestor/attestation_test.go | 22 ++ 9 files changed, 920 insertions(+), 81 deletions(-) create mode 100644 cmd/qbftattestor/cbdc_test.go diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index ac9e60b9..a9b280ef 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/crypto" ) @@ -27,6 +28,10 @@ type besuClient struct { // 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 { @@ -34,6 +39,11 @@ type sendPacket struct { txHash string } +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))) @@ -107,15 +117,102 @@ func (b *besuClient) sendPackets(ctx context.Context, router, clientID string) ( 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() + res, err := b.call(ctx, "eth_getLogs", []any{map[string]any{ + "address": router, "fromBlock": fmt.Sprintf("0x%x", b.ackScanned), "toBlock": "latest", "topics": []any{topic}, + }}) + if err != nil { + return nil, err + } + var logs []struct { + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + } + if err := json.Unmarshal(res, &logs); err != nil { + return nil, err + } + out := make([]writeAck, 0, len(logs)) + for _, l := range logs { + // Cursor discipline mirrors sendPackets: advance only past blocks whose + // logs this query actually returned. + if bn, err := strconv.ParseUint(strings.TrimPrefix(l.BlockNumber, "0x"), 16, 64); err == nil && bn > b.ackScanned { + b.ackScanned = bn + } + 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)", receiptCommitmentKey(clientID, seq)) + "getCommitment(bytes32)(bytes32)", key) if err != nil { - // Fail CLOSED: an unreachable node must not be read as "not yet - // delivered", which would redeliver on every tick. - return false, fmt.Errorf("receipt check failed, refusing to assume: %w", err) + // 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 } @@ -132,6 +229,16 @@ func (b *besuClient) recvPacket(ctx context.Context, pk, router, tuple string, p 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 { @@ -161,81 +268,132 @@ func (c *cbdcRPC) latestHeight(ctx context.Context) (uint64, error) { 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 []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"` - } `json:"txs"` + Txs []searchedTx `json:"txs"` // total_count is a STRING in CometBFT's JSON, not a number. TotalCount string `json:"total_count"` } `json:"result"` } -func (c *cbdcRPC) sendEvents(ctx context.Context, clientID string) (map[uint64]string, error) { - // 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. +// 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 - res := map[uint64]string{} - fetched := 0 + var res []searchedTx for page := 1; ; page++ { q := url.Values{} - q.Set("query", fmt.Sprintf("\"send_packet.packet_source_client='%s'\"", clientID)) + 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 } - for _, tx := range out.Result.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 - } - } - } - fetched += len(out.Result.Txs) + 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 fetched >= total || len(out.Result.Txs) < perPage { + 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, and on -// this rig acks are never relayed so commitments never clear anyway. The -// caller's receipt check is what separates delivered from undelivered -- the -// honest name keeps the monotonic growth from reading like a filtering bug. +// "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 { @@ -262,7 +420,17 @@ func (c *cbdcRPC) packetHex(ctx context.Context, clientID string, seq uint64) (s // 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) { - path := receiptPath(clientID, seq) + 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)) @@ -281,7 +449,17 @@ func (c *cbdcRPC) packetReceived(ctx context.Context, clientID string, seq uint6 // receiptPath is clientID || 0x02 || be64(seq) -- kind 2 is the receipt. func receiptPath(clientID string, seq uint64) []byte { - p := append([]byte(clientID), 0x02) + 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)) @@ -298,6 +476,14 @@ 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) { @@ -325,7 +511,14 @@ func (d *driver) relayInbound(ctx context.Context, p sendPacket) error { 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; +// cbdcd 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 @@ -347,13 +540,15 @@ func (d *driver) relayInbound(ctx context.Context, p sendPacket) error { return err } defer os.RemoveAll(dir) - unsigned := filepath.Join(dir, fmt.Sprintf("recv-%d.json", p.sequence)) - if _, err := run(ctx, "go", "run", "./cmd/qbftrelay", + 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", pkt, "-trusted-height", strconv.FormatUint(trusted, 10), + "-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); err != nil { + "-signer", d.cfg.signer, "-out", unsigned} + args = append(args, extra...) + if _, err := run(ctx, "go", args...); err != nil { return fmt.Errorf("qbftrelay: %w", err) } diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index 3c208048..4d1c68fc 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -37,6 +37,8 @@ import ( "strings" "syscall" "time" + + channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" ) type config struct { @@ -87,6 +89,8 @@ func main() { 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{}, } @@ -117,6 +121,8 @@ type driver struct { 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 } @@ -127,6 +133,15 @@ func (d *driver) tick(ctx context.Context) error { 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) + } return nil } @@ -156,16 +171,8 @@ func (d *driver) outbound(ctx context.Context) error { return err } // The commitment must be visible at the height we attest. - if !d.attested[h] { - 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("outbound: client advanced to cbdc height %d", h) + 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 { @@ -188,6 +195,26 @@ func (d *driver) outbound(ctx context.Context) error { 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) @@ -215,6 +242,116 @@ func (d *driver) inbound(ctx context.Context) error { 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)) diff --git a/cmd/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go index d12375a5..51d6276a 100644 --- a/cmd/qbftattestor/cbdc.go +++ b/cmd/qbftattestor/cbdc.go @@ -102,6 +102,68 @@ func (c *cbdcClient) commitment(ctx context.Context, path []byte, height uint64) 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)) + q.Set("prove", "1") + + 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: diff --git a/cmd/qbftattestor/cbdc_test.go b/cmd/qbftattestor/cbdc_test.go new file mode 100644 index 00000000..6baf8e7e --- /dev/null +++ b/cmd/qbftattestor/cbdc_test.go @@ -0,0 +1,222 @@ +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=1 is what turns "version missing" into a hard error server-side; + // dropping it silently reopens the pruned-height ambiguity. + require.Equal(t, "1", 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(w http.ResponseWriter, r *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: 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(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: 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: 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: 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(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 index eafece33..bb256c1a 100644 --- a/cmd/qbftattestor/grpc.go +++ b/cmd/qbftattestor/grpc.go @@ -55,9 +55,17 @@ func (a *aggregatorServer) GetAttestations(ctx context.Context, req *pb.GetAttes return nil, err } - // req.Packets carries the ICS-24 commitment paths to attest. We hash them - // ourselves and read each commitment from our own node -- a caller cannot - // smuggle in a commitment value. + // 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) diff --git a/cmd/qbftattestor/main.go b/cmd/qbftattestor/main.go index 4b38416e..6255d70b 100644 --- a/cmd/qbftattestor/main.go +++ b/cmd/qbftattestor/main.go @@ -193,6 +193,8 @@ func main() { 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": crypto.PubkeyToAddress(key.PublicKey).Hex()}) }) @@ -273,6 +275,127 @@ func (s *server) attestPacket(w http.ResponseWriter, r *http.Request) { 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. +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) diff --git a/cmd/qbftrelay/main.go b/cmd/qbftrelay/main.go index b8de3675..7cfcf699 100644 --- a/cmd/qbftrelay/main.go +++ b/cmd/qbftrelay/main.go @@ -53,6 +53,8 @@ func main() { 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") @@ -65,6 +67,14 @@ func main() { 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, @@ -77,6 +87,8 @@ func main() { gasLimit: *gasLimit, out: *out, timeout: *timeoutMode, + ack: *ackMode, + ackHex: *ackHex, } if err := run(context.Background(), cfg, *packetHex); err != nil { @@ -96,6 +108,8 @@ type config struct { gasLimit uint64 out string timeout bool + ack bool + ackHex string } func run(ctx context.Context, cfg config, packetHex string) error { @@ -144,22 +158,38 @@ func run(ctx context.Context, cfg config, packetHex string) error { return err } - // Two directions, two proofs. A receive proves the commitment is PRESENT in the - // counterparty's store; a timeout proves the receipt is ABSENT, which is what - // entitles this chain to refund its own escrow. The receipt is keyed by the - // packet's DESTINATION client, because that is the store it would have been - // written into. + // Three message kinds, three proofs. A receive proves the commitment is PRESENT + // in the counterparty's store; a timeout proves the receipt is ABSENT, which is + // what entitles this chain to refund its own escrow; an ack proves the + // acknowledgement is PRESENT, which clears this chain's commitment for a packet + // the counterparty received. Receipt and ack are both keyed by the packet's + // DESTINATION client, because that is the store they were written into. var ( proof *types.StorageProof final sdk.Msg ) - if cfg.timeout { + switch { + case cfg.timeout: proof, err = p.PacketReceiptProof(ctx, packet.DestinationClient, packet.Sequence, cfg.target) if err != nil { return err } final, err = msgs.Timeout(encCfg.Codec, packet, proof, cfg.target, cfg.signer) - } else { + case cfg.ack: + ackBz, decErr := hex.DecodeString(cfg.ackHex) + if decErr != nil { + return fmt.Errorf("decode ack hex: %w", decErr) + } + proof, err = p.PacketAckProof(ctx, packet.DestinationClient, packet.Sequence, cfg.target) + if err != nil { + return err + } + // The wire form is the RAW app ack; ibc-go recomputes the commitment + // (sha256(0x02 || sha256(ack))) itself and checks the proof against it, + // so a wrong ack fails verification rather than clearing the commitment. + ack := channeltypesv2.Acknowledgement{AppAcknowledgements: [][]byte{ackBz}} + final, err = msgs.Acknowledgement(encCfg.Codec, packet, ack, proof, cfg.target, cfg.signer) + default: proof, err = p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, cfg.target) if err != nil { return err diff --git a/x/qbftclient/attestor/attestation.go b/x/qbftclient/attestor/attestation.go index 1910a9cd..11787f37 100644 --- a/x/qbftclient/attestor/attestation.go +++ b/x/qbftclient/attestor/attestation.go @@ -162,11 +162,10 @@ func PacketProof(key *ecdsa.PrivateKey, height uint64, packets []PacketCompact) return EncodeProof(data, [][]byte{sig}) } -// CommitmentPath returns the full ICS-24 commitment path for a packet: -// sourceClient || 0x01 || be64(sequence). The spoke's registered merkle prefix -// is a single EMPTY element, so this is exactly what the light client hashes. -func CommitmentPath(sourceClient string, sequence uint64) []byte { - p := append([]byte(sourceClient), 0x01) +// ics24Path assembles clientID || kind || be64(sequence), the layout shared by +// all three packet stores (ICS24Host.sol uses the same abi.encodePacked shape). +func ics24Path(clientID string, kind byte, sequence uint64) []byte { + p := append([]byte(clientID), kind) var be [8]byte for i := 0; i < 8; i++ { be[7-i] = byte(sequence >> (8 * i)) @@ -174,7 +173,48 @@ func CommitmentPath(sourceClient string, sequence uint64) []byte { return append(p, be[:]...) } +// CommitmentPath returns the full ICS-24 commitment path for a packet: +// sourceClient || 0x01 || be64(sequence). The spoke's registered merkle prefix +// is a single EMPTY element, so this is exactly what the light client hashes. +func CommitmentPath(sourceClient string, sequence uint64) []byte { + return ics24Path(sourceClient, 0x01, sequence) +} + // PathHash is the value that goes in PacketCompact.Path. func PathHash(sourceClient string, sequence uint64) [32]byte { return common.BytesToHash(crypto.Keccak256(CommitmentPath(sourceClient, sequence))) } + +// ReceiptPath returns the full ICS-24 receipt path for a packet: +// destClient || 0x02 || be64(sequence). Kind 2, NOT the kind 1 commitment +// path: the receipt is written by the packet's DESTINATION, keyed by the +// destination's client id, and its ABSENCE is what a timeout attests. Hashing +// the commitment path here would attest non-membership of a key the router +// never checks, so the timeout would simply not verify -- but the mirror +// mistake (kind 2 where kind 1 belongs) would attest a zero commitment for a +// packet that exists, which is why the kind byte is not a parameter. +func ReceiptPath(destClient string, sequence uint64) []byte { + return ics24Path(destClient, 0x02, sequence) +} + +// ReceiptPathHash is the value that goes in PacketCompact.Path for a +// non-membership attestation. The contract accepts a timeout only if this +// exact hash appears in the attested set with Commitment == bytes32(0) +// (AttestationLightClient.verifyNonMembership). +func ReceiptPathHash(destClient string, sequence uint64) [32]byte { + return common.BytesToHash(crypto.Keccak256(ReceiptPath(destClient, sequence))) +} + +// AckPath returns the full ICS-24 acknowledgement path for a packet: +// destClient || 0x03 || be64(sequence). Written by the packet's DESTINATION +// when it receives; proving it as an ordinary membership is what lets the +// source chain's router clear the packet commitment (ackPacket). +func AckPath(destClient string, sequence uint64) []byte { + return ics24Path(destClient, 0x03, sequence) +} + +// AckPathHash is the value that goes in PacketCompact.Path for an +// acknowledgement membership attestation. +func AckPathHash(destClient string, sequence uint64) [32]byte { + return common.BytesToHash(crypto.Keccak256(AckPath(destClient, sequence))) +} diff --git a/x/qbftclient/attestor/attestation_test.go b/x/qbftclient/attestor/attestation_test.go index e1ec1362..7e95ccf5 100644 --- a/x/qbftclient/attestor/attestation_test.go +++ b/x/qbftclient/attestor/attestation_test.go @@ -100,6 +100,28 @@ func TestCommitmentPath_Layout(t *testing.T) { "must be sourceClient || 0x01 || be64(sequence)") } +// Receipt and ack paths differ from the commitment path ONLY in the kind byte +// (0x02 and 0x03 vs 0x01). The goldens pin all three: a swapped kind would +// attest the wrong store -- worst case a zero commitment for a receipt that +// exists -- while every byte around it stays plausible. +func TestReceiptPath_Layout(t *testing.T) { + got := ReceiptPath("qbftclient-0", 1) + require.Equal(t, "71626674636c69656e742d30020000000000000001", hex.EncodeToString(got), + "must be destClient || 0x02 || be64(sequence)") +} + +func TestAckPath_Layout(t *testing.T) { + got := AckPath("qbftclient-0", 1) + require.Equal(t, "71626674636c69656e742d30030000000000000001", hex.EncodeToString(got), + "must be destClient || 0x03 || be64(sequence)") +} + +func TestPathKinds_AreDistinct(t *testing.T) { + require.NotEqual(t, PathHash("c", 1), ReceiptPathHash("c", 1)) + require.NotEqual(t, ReceiptPathHash("c", 1), AckPathHash("c", 1), + "one hash serving two stores would let a membership attest stand in for a non-membership") +} + func TestPacketProof_RoundTrips(t *testing.T) { key, err := crypto.GenerateKey() require.NoError(t, err) From 605de989a1c2d05d6f263095052b0944f7c77176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 14:39:54 +0200 Subject: [PATCH 29/61] feat(contracts): bring HondurasCBDC.sol into the repo It was written into a temporary checkout of solidity-ibc-eureka and would have been lost. It belongs here: it is our own contract, not a fork of upstream, so it does not touch the audit that forking would forfeit. Added contracts/spoke/ with a README recording why the contract exists at all -- ICS-20 carries only the denom string, IBCERC20 lost its setMetadata in v3.0.0, so a voucher's name can only be chosen by pre-registering a token before the first packet -- and the two warnings that go with it: the window shuts at the first packet, and setCustomERC20 validates nothing about the address it is given while that contract holds mint authority. Co-Authored-By: Claude Opus 5 (1M context) --- contracts/spoke/HondurasCBDC.sol | 45 ++++++++++++++++++++++++++++++++ contracts/spoke/README.md | 29 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 contracts/spoke/HondurasCBDC.sol create mode 100644 contracts/spoke/README.md diff --git a/contracts/spoke/HondurasCBDC.sol b/contracts/spoke/HondurasCBDC.sol new file mode 100644 index 00000000..2dd54054 --- /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 00000000..801c7d9a --- /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. From cb3e40209ba20676c02333e077c589c68dd523da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 15:20:30 +0200 Subject: [PATCH 30/61] fix: CLI mint/burn, Besu log-range limit, and a dead refund path Three real defects, all found by running things rather than reading them. x/cbdc's mint and burn were unusable from the CLI. autocli's Coin flag binder produces a pulsar Coin, x/cbdc has no pulsar codegen so its messages resolve to dynamicpb, and proto.Merge across the two descriptor families panics unconditionally inside BuildMsgMethodCommand -- no autocli option avoids it, because the merge happens before any of them apply. Hand-written cobra commands keep the same --address/--amount surface. Verified against the live chain: mint and burn both return code 0 and move the balance. Trade-off: tx cbdc update-params is no longer auto-generated, and it is gov-gated so it was not usable from the CLI anyway. corridord scanned eth_getLogs from block 0 to latest, which exceeds Besu's ~5000-block RPC range limit, so a fresh start against any non-trivial chain failed every tick with "Requested range exceeds maximum RPC range limit". It now scans in 4000-block windows and advances its cursor only over fully processed coverage. On restart it immediately cleared acks that had been stranded by this. The absence attestation added for F1 was dead on arrival: it sent prove=1, but CometBFT decodes prove as a JSON bool and 500s on 1, so every request was refused and the Besu refund path could never have worked. The unit test asserted "1" against a mock, which is why it passed while the real thing could not function -- both are now "true". A mock that agrees with the code rather than the server proves nothing. Also: outbound and inbound never checked whether the send commitment still existed, so a timed-out and refunded packet was retried forever. Both timeout directions are now exercised end to end. besu->cbdc via /attest/absence and ICS26Router.timeoutPacket, with the faucet's balance returning exactly. cbdc->besu via qbftrelay -as-timeout and MsgTimeout, with the escrowed amount returning exactly. Recorded rather than fixed: ahnl can never exist -- x/cbdc pins the denom to acbdc in the keeper -- so that pre-registration is unreachable. The named-token mechanism is proven instead on a denom the chain will carry. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/corridord/chains.go | 110 ++++++++++++++++++++----------- cmd/corridord/main.go | 25 +++++++ cmd/qbftattestor/cbdc.go | 5 +- cmd/qbftattestor/cbdc_test.go | 8 ++- x/cbdc/client/cli/tx.go | 120 ++++++++++++++++++++++++++++++++++ x/cbdc/module.go | 9 +++ 6 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 x/cbdc/client/cli/tx.go diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index a9b280ef..be8d3dc1 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -24,9 +24,9 @@ import ( type besuClient struct { rpc string - // lastScanned is the highest block a SendPacket log has been seen in. - // Subsequent eth_getLogs queries start here instead of 0, so the scan - // window stops growing with chain length. + // 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 @@ -39,6 +39,65 @@ type sendPacket struct { 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 @@ -80,31 +139,16 @@ func (b *besuClient) sendPackets(ctx context.Context, router, clientID string) ( // 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() - res, err := b.call(ctx, "eth_getLogs", []any{map[string]any{ - "address": router, "fromBlock": fmt.Sprintf("0x%x", b.lastScanned), "toBlock": "latest", "topics": []any{topic}, - }}) + // 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 } - var logs []struct { - Topics []string `json:"topics"` - TxHash string `json:"transactionHash"` - BlockNumber string `json:"blockNumber"` - } - if err := json.Unmarshal(res, &logs); err != nil { - return nil, err - } + b.lastScanned = covered out := make([]sendPacket, 0, len(logs)) for _, l := range logs { - // Advance the scan cursor only to blocks whose logs we actually - // received, never to the chain head: eth_blockNumber could name a - // block this query did not cover, silently skipping packets. The - // last log-bearing block is re-scanned next tick (fromBlock is - // inclusive), which is harmless -- the caller's receipt check drops - // duplicates -- and an empty result leaves the cursor untouched. - if bn, err := strconv.ParseUint(strings.TrimPrefix(l.BlockNumber, "0x"), 16, 64); err == nil && bn > b.lastScanned { - b.lastScanned = bn - } if len(l.Topics) < 3 || !strings.EqualFold(l.Topics[1], clientTopic) { continue } @@ -150,27 +194,15 @@ func (b *besuClient) writeAcks(ctx context.Context, router, clientID string) ([] "WriteAcknowledgement(string,uint256,(uint64,string,string,uint64,(string,string,string,string,bytes)[]),bytes[])", )).Hex() clientTopic := crypto.Keccak256Hash([]byte(clientID)).Hex() - res, err := b.call(ctx, "eth_getLogs", []any{map[string]any{ - "address": router, "fromBlock": fmt.Sprintf("0x%x", b.ackScanned), "toBlock": "latest", "topics": []any{topic}, - }}) + // 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 } - var logs []struct { - Topics []string `json:"topics"` - Data string `json:"data"` - BlockNumber string `json:"blockNumber"` - } - if err := json.Unmarshal(res, &logs); err != nil { - return nil, err - } + b.ackScanned = covered out := make([]writeAck, 0, len(logs)) for _, l := range logs { - // Cursor discipline mirrors sendPackets: advance only past blocks whose - // logs this query actually returned. - if bn, err := strconv.ParseUint(strings.TrimPrefix(l.BlockNumber, "0x"), 16, 64); err == nil && bn > b.ackScanned { - b.ackScanned = bn - } if len(l.Topics) < 3 || !strings.EqualFold(l.Topics[1], clientTopic) { continue } diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index 4d1c68fc..17a89bca 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -166,6 +166,19 @@ func (d *driver) outbound(ctx context.Context) error { 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 @@ -233,6 +246,18 @@ func (d *driver) inbound(ctx context.Context) error { 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) } diff --git a/cmd/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go index 51d6276a..c110e67a 100644 --- a/cmd/qbftattestor/cbdc.go +++ b/cmd/qbftattestor/cbdc.go @@ -130,7 +130,10 @@ func (c *cbdcClient) provenAbsent(ctx context.Context, path []byte, height uint6 q.Set("path", `"store/ibc/key"`) q.Set("data", "0x"+hex.EncodeToString(path)) q.Set("height", fmt.Sprintf("%d", height)) - q.Set("prove", "1") + // "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 { diff --git a/cmd/qbftattestor/cbdc_test.go b/cmd/qbftattestor/cbdc_test.go index 6baf8e7e..89e50fc0 100644 --- a/cmd/qbftattestor/cbdc_test.go +++ b/cmd/qbftattestor/cbdc_test.go @@ -63,9 +63,11 @@ func TestProvenAbsent_AcceptsOnlyAProvenEmptyAnswer(t *testing.T) { c := &cbdcClient{rpc: srv.URL} require.NoError(t, c.provenAbsent(context.Background(), []byte("path"), 7)) - // prove=1 is what turns "version missing" into a hard error server-side; - // dropping it silently reopens the pruned-height ambiguity. - require.Equal(t, "1", q["prove"]) + // 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"]) } diff --git a/x/cbdc/client/cli/tx.go b/x/cbdc/client/cli/tx.go new file mode 100644 index 00000000..19f1b150 --- /dev/null +++ b/x/cbdc/client/cli/tx.go @@ -0,0 +1,120 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/client/tx" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/peersyst/cbdc-node/x/cbdc/types" +) + +const ( + flagAddress = "address" + flagAmount = "amount" +) + +// GetTxCmd returns hand-written tx commands for x/cbdc. autocli cannot +// generate working mint/burn commands here: its Coin flag builds a +// cosmossdk.io/api (pulsar) Coin, while this module has no pulsar codegen so +// its messages resolve to dynamicpb, and proto.Merge across the two +// descriptor families panics with "field descriptor does not belong to this +// message". Providing a custom tx command makes autocli use it instead. +func GetTxCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: types.ModuleName, + Short: fmt.Sprintf("%s transactions subcommands", types.ModuleName), + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + cmd.AddCommand(CmdMint(), CmdBurn()) + + return cmd +} + +// CmdMint returns the command to mint CBDC tokens to an address. +func CmdMint() *cobra.Command { + cmd := &cobra.Command{ + Use: "mint --address [address] --amount [coin]", + Short: "Mint CBDC tokens and send them to an address", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + clientCtx, address, amount, err := mintBurnArgs(cmd) + if err != nil { + return err + } + msg := &types.MsgMint{ + Owner: clientCtx.GetFromAddress().String(), + Address: address, + Amount: amount, + } + return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg) + }, + } + + addMintBurnFlags(cmd) + + return cmd +} + +// CmdBurn returns the command to burn CBDC tokens from an address. +func CmdBurn() *cobra.Command { + cmd := &cobra.Command{ + Use: "burn --address [address] --amount [coin]", + Short: "Burn CBDC tokens from an address", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + clientCtx, address, amount, err := mintBurnArgs(cmd) + if err != nil { + return err + } + msg := &types.MsgBurn{ + Owner: clientCtx.GetFromAddress().String(), + Address: address, + Amount: amount, + } + return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg) + }, + } + + addMintBurnFlags(cmd) + + return cmd +} + +// addMintBurnFlags keeps the flag names identical to the ones the autocli +// command advertised (--address / --amount) so existing invocations keep +// working. +func addMintBurnFlags(cmd *cobra.Command) { + cmd.Flags().String(flagAddress, "", "address to mint to / burn from") + cmd.Flags().String(flagAmount, "", "amount, e.g. 1000acbdc") + flags.AddTxFlagsToCmd(cmd) + _ = cmd.MarkFlagRequired(flagAddress) + _ = cmd.MarkFlagRequired(flagAmount) +} + +func mintBurnArgs(cmd *cobra.Command) (client.Context, string, sdk.Coin, error) { + clientCtx, err := client.GetClientTxContext(cmd) + if err != nil { + return client.Context{}, "", sdk.Coin{}, err + } + address, err := cmd.Flags().GetString(flagAddress) + if err != nil { + return client.Context{}, "", sdk.Coin{}, err + } + amountStr, err := cmd.Flags().GetString(flagAmount) + if err != nil { + return client.Context{}, "", sdk.Coin{}, err + } + amount, err := sdk.ParseCoinNormalized(amountStr) + if err != nil { + return client.Context{}, "", sdk.Coin{}, fmt.Errorf("invalid --amount %q: %w", amountStr, err) + } + return clientCtx, address, amount, nil +} diff --git a/x/cbdc/module.go b/x/cbdc/module.go index c69bf5ce..97629686 100644 --- a/x/cbdc/module.go +++ b/x/cbdc/module.go @@ -14,8 +14,10 @@ import ( "github.com/cosmos/cosmos-sdk/types/module" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/peersyst/cbdc-node/x/cbdc/client/cli" "github.com/peersyst/cbdc-node/x/cbdc/keeper" "github.com/peersyst/cbdc-node/x/cbdc/types" + "github.com/spf13/cobra" ) var ( @@ -67,6 +69,13 @@ func (AppModuleBasic) RegisterGRPCGatewayRoutes(clientCtx client.Context, mux *r } } +// GetTxCmd returns hand-written mint/burn commands instead of letting autocli +// derive them: autocli's Coin flag panics for this module (pulsar Coin merged +// into a dynamicpb message — see x/cbdc/client/cli/tx.go for the details). +func (AppModuleBasic) GetTxCmd() *cobra.Command { + return cli.GetTxCmd() +} + // ---------------------------------------------------------------------------- // AppModule // ---------------------------------------------------------------------------- From 90fc536dfb48b8b5903aa35ec96417f3da52b0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 31 Jul 2026 15:55:48 +0200 Subject: [PATCH 31/61] feat(qbftproofapi): proof-API shim so cosmos/ibc-relayer can drive the corridor DEC-18 names cosmos/ibc-relayer as the driver; corridord was always a rig tool standing in for it. The blocker was that the relayer has ONE global proof-API endpoint rather than a per-route map, so a single service has to answer for both directions -- inbound needs QBFT/MPT proofs nothing upstream can produce, outbound needs attestations. The shim implements RelayByTx only; the other three methods return Unimplemented because the relayer never calls them. Inbound it builds an unsigned TxBody of [MsgUpdateClient, MsgRecvPacket...] with signer set to the relayer's own bech32 address, since the SDK requires msg-signer to equal tx-signer and the relayer never rewrites the field. Outbound it packs router multicall calldata around attestations fetched from the sidecar. It holds no keys: the relayer signs and broadcasts everything itself. prover/relaytx factors UpdateMsgs/RecvMsgs out of cmd/qbftrelay so the two share one implementation rather than drifting. Spec corrections found by building it. Dispatch cannot key on the (src,dst) chain pair alone: upstream swaps src and dst for acks and timeouts, so those arrive with the same pair as the opposite-direction recv and must be discriminated on timeout_tx_ids and on dst_packet_sequences arriving without src_packet_sequences. The relayer signs cosmos txs with standard secp256k1, not eth_secp256k1, so its identity is the ripemd160(sha256) address -- verified against the chain's ante before wiring anything. And the v2 send path takes timeouts in seconds while the CLI writes nanoseconds, which the chain rejects. Acks and timeouts are deliberately out of scope for the shim, so send commitments stay set after a relayed packet even though the tokens move. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/qbftproofapi/evm.go | 183 +++++++++++++++++++++++ cmd/qbftproofapi/inbound.go | 156 ++++++++++++++++++++ cmd/qbftproofapi/main.go | 192 +++++++++++++++++++++++++ cmd/qbftproofapi/outbound.go | 122 ++++++++++++++++ cmd/qbftrelay/main.go | 34 ++--- x/qbftclient/proofapipb/service.go | 103 +++++++++++++ x/qbftclient/prover/relaytx/relaytx.go | 88 ++++++++++++ 7 files changed, 855 insertions(+), 23 deletions(-) create mode 100644 cmd/qbftproofapi/evm.go create mode 100644 cmd/qbftproofapi/inbound.go create mode 100644 cmd/qbftproofapi/main.go create mode 100644 cmd/qbftproofapi/outbound.go create mode 100644 x/qbftclient/proofapipb/service.go create mode 100644 x/qbftclient/prover/relaytx/relaytx.go diff --git a/cmd/qbftproofapi/evm.go b/cmd/qbftproofapi/evm.go new file mode 100644 index 00000000..abe7ac46 --- /dev/null +++ b/cmd/qbftproofapi/evm.go @@ -0,0 +1,183 @@ +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":"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 +} + +// 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 +} + +// fromSendPacketLog decodes one SendPacket event payload into the protobuf +// packet MsgRecvPacket carries. Inverse of toSolPacket, same mapping as +// cmd/packetconv. +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) + } + + 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, 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) +} diff --git a/cmd/qbftproofapi/inbound.go b/cmd/qbftproofapi/inbound.go new file mode 100644 index 00000000..7771f9bf --- /dev/null +++ b/cmd/qbftproofapi/inbound.go @@ -0,0 +1,156 @@ +package main + +// Inbound: Besu -> cbdc-node. Real MPT proofs out of the router's storage, +// assembled into the unsigned TxBody the relayer signs and broadcasts. + +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/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) + } + + trusted, err := s.clientLatestHeight(ctx, dstClient) + if err != nil { + return nil, err + } + head, err := eth.BlockNumber(ctx) + if err != nil { + return nil, fmt.Errorf("besu head: %w", err) + } + + // 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. + target := head + var msgs []sdk.Msg + if target > trusted { + updates, err := relaytx.UpdateMsgs(ctx, chain, s.cfg.router, dstClient, trusted, target, s.cfg.signer) + if err != nil { + return nil, err + } + msgs = updates + } else { + target = trusted + } + + recvs, err := relaytx.RecvMsgs(ctx, chain, s.cfg.router, s.cdc, packets, target, s.cfg.signer) + if err != nil { + return nil, err + } + msgs = append(msgs, recvs...) + + 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) + } + // 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 above carries the relayer's address as signer. + 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 +} + +// 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 any codectypes.Any + if err := any.Unmarshal(res.Response.Value); err != nil { + return 0, fmt.Errorf("unmarshal client state any: %w", err) + } + var cs qbfttypes.ClientState + if err := cs.Unmarshal(any.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 00000000..7bb326d7 --- /dev/null +++ b/cmd/qbftproofapi/main.go @@ -0,0 +1,192 @@ +// 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: +// +// - Besu -> cbdc-node: build the UNSIGNED cosmos TxBody carrying +// [MsgUpdateClient..., MsgRecvPacket...], 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. +// - cbdc-node -> Besu: build ICS26Router multicall calldata +// [updateClient, recvPacket...], attestations fetched from the attestor +// sidecar's AggregatorService. The sidecar verifies against its own +// cbdc-node view before signing, so nothing a caller sends here can smuggle +// in a commitment. +// +// 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" + + 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/attestor/aggregatorpb" + "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 +} + +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 AggregatorService") + 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") + flag.Parse() + + if routerHex == "" || cfg.signer == "" { + fmt.Fprintln(os.Stderr, "required: -router -signer") + 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: aggregatorpb.NewAggregatorServiceClient(attConn), + } + + 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, proofs via x/qbftclient/prover, msgs signed by %s", cfg.besuChainID, cfg.cbdcChainID, cfg.signer) + log.Printf(" %s -> %s : ICS26Router multicall via attestor at %s", cfg.cbdcChainID, cfg.besuChainID, cfg.attestorGRPC) + if err := grpcSrv.Serve(lis); err != nil { + log.Fatalf("serve: %v", err) + } +} + +type server struct { + cfg config + cdc codec.Codec + cbdc *rpchttp.HTTP + attestor aggregatorpb.AggregatorServiceClient +} + +// RelayByTx is the one method cosmos/ibc-relayer invokes. Dispatch is on the +// exact chain_id strings the relayer was configured with. +func (s *server) RelayByTx(ctx context.Context, req *proofapipb.RelayByTxRequest) (*proofapipb.RelayByTxResponse, error) { + // The recv/ack/timeout shapes share one RPC and, for acks and timeouts, the + // same (src,dst) pair as the opposite recv direction. They are told apart by + // which fields are set (see upstream batch_ack_packet.go / timeout_packet.go): + // timeouts fill timeout_tx_ids, acks fill dst_packet_sequences only. This + // shim is recv-only — the relayer config disables acks, and timeouts are + // avoided with generous packet timeouts — so anything else is refused + // loudly rather than mis-relayed as a receive. + if len(req.GetTimeoutTxIds()) > 0 { + return nil, status.Error(codes.Unimplemented, "timeout relaying is not supported by this shim") + } + if len(req.GetDstPacketSequences()) > 0 && len(req.GetSrcPacketSequences()) == 0 { + return nil, status.Error(codes.Unimplemented, "ack relaying is not supported by this shim") + } + if 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: + 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: + 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) { + if err != nil { + log.Printf("%s seqs=%v: %v", dir, req.GetSrcPacketSequences(), err) + return + } + log.Printf("%s seqs=%v: ok (%d txs)", dir, req.GetSrcPacketSequences(), len(req.GetSourceTxIds())) +} + +// 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 00000000..ef58e36c --- /dev/null +++ b/cmd/qbftproofapi/outbound.go @@ -0,0 +1,122 @@ +package main + +// Outbound: cbdc-node -> Besu. No proof to build — the attestor sidecar +// attests (state + packet membership) and the AttestationLightClient checks +// signatures. This shim only asks, wraps, and ABI-encodes. + +import ( + "context" + "encoding/hex" + "fmt" + + 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/aggregatorpb" + "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 := uint64(st.SyncInfo.LatestBlockHeight) + + // One attestation covers every packet in the batch: the sidecar reads each + // commitment from its own cbdc-node at `height` and refuses anything it + // cannot verify, so a bogus sequence fails here, not on-chain. + paths := make([][]byte, 0, len(packets)) + for _, pk := range packets { + paths = append(paths, attestor.CommitmentPath(pk.SourceClient, pk.Sequence)) + } + att, err := s.attestor.GetAttestations(ctx, &aggregatorpb.GetAttestationsRequest{ + Height: height, + Packets: paths, + }) + if err != nil { + return nil, fmt.Errorf("attestor: %w", err) + } + if att.GetStateAttestation() == nil || att.GetPacketAttestation() == nil { + return nil, fmt.Errorf("attestor returned incomplete attestations for height %d", height) + } + + // The light client verifies abi.encode(AttestationProof{data, signatures}); + // the aggregator hands back the two halves unwrapped. + stateProof, err := attestor.EncodeProof(att.GetStateAttestation().GetAttestedData(), att.GetStateAttestation().GetSignatures()) + if err != nil { + return nil, fmt.Errorf("encode state proof: %w", err) + } + packetProof, err := attestor.EncodeProof(att.GetPacketAttestation().GetAttestedData(), att.GetPacketAttestation().GetSignatures()) + if err != nil { + return nil, fmt.Errorf("encode packet proof: %w", 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 +} + +// 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 +} diff --git a/cmd/qbftrelay/main.go b/cmd/qbftrelay/main.go index 7cfcf699..f1be88d8 100644 --- a/cmd/qbftrelay/main.go +++ b/cmd/qbftrelay/main.go @@ -41,6 +41,7 @@ import ( "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/msgs" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/relaytx" "github.com/peersyst/cbdc-node/x/qbftclient/types" ) @@ -135,29 +136,15 @@ func run(ctx context.Context, cfg config, packetHex string) error { } defer chain.Close() - // The set the client trusts is the set carried by the header it last verified, - // so it is read from the counterparty rather than passed in — one fewer flag to - // get wrong, and it cannot disagree with the chain. - trustedHeader, err := chain.HeaderByNumber(ctx, cfg.trusted) - if err != nil { - return err - } - trustedSet, err := types.HeaderValidators(trustedHeader) + // 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 } p := prover.New(chain, cfg.contract) - headers, err := p.UpdateChain(ctx, trustedSet, cfg.trusted, cfg.target) - if err != nil { - return err - } - updates, err := msgs.UpdateClientChain(cfg.clientID, headers, cfg.signer) - if err != nil { - return err - } - // Three message kinds, three proofs. A receive proves the commitment is PRESENT // in the counterparty's store; a timeout proves the receipt is ABSENT, which is // what entitles this chain to refund its own escrow; an ack proves the @@ -190,11 +177,12 @@ func run(ctx context.Context, cfg config, packetHex string) error { ack := channeltypesv2.Acknowledgement{AppAcknowledgements: [][]byte{ackBz}} final, err = msgs.Acknowledgement(encCfg.Codec, packet, ack, proof, cfg.target, cfg.signer) default: - proof, err = p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, cfg.target) - if err != nil { - return err + recvs, recvErr := relaytx.RecvMsgs(ctx, chain, cfg.contract, encCfg.Codec, + []channeltypesv2.Packet{packet}, cfg.target, cfg.signer) + if recvErr != nil { + return recvErr } - final, err = msgs.RecvPacket(encCfg.Codec, packet, proof, cfg.target, cfg.signer) + final = recvs[0] } recv := final if err != nil { @@ -221,8 +209,8 @@ func run(ctx context.Context, cfg config, packetHex string) error { 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(headers) > 1 { - fmt.Printf("updates %d headers (the validator set changed in this range)\n", len(headers)) + 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") } diff --git a/x/qbftclient/proofapipb/service.go b/x/qbftclient/proofapipb/service.go new file mode 100644 index 00000000..b2f3084f --- /dev/null +++ b/x/qbftclient/proofapipb/service.go @@ -0,0 +1,103 @@ +package proofapipb + +// gRPC glue for ProofApiService, hand-written because the proto-builder image +// ships protoc-gen-go but not protoc-gen-go-grpc (same situation as +// attestor/aggregatorpb). Method and service names must match upstream +// cosmos/ibc-relayer's generated client byte for byte — the relayer dials +// "/proofapi.ProofApiService/RelayByTx" — so they are spelled out here rather +// than derived. + +import ( + context "context" + + grpc "google.golang.org/grpc" +) + +const serviceName = "proofapi.ProofApiService" + +// ProofApiServiceServer is the server API for ProofApiService. +// +// cosmos/ibc-relayer only ever invokes RelayByTx at runtime; the other three +// methods exist so the service surface matches upstream's proto, and a server +// is free to reject them with codes.Unimplemented. +type ProofApiServiceServer interface { + RelayByTx(context.Context, *RelayByTxRequest) (*RelayByTxResponse, error) + CreateClient(context.Context, *CreateClientRequest) (*CreateClientResponse, error) + UpdateClient(context.Context, *UpdateClientRequest) (*UpdateClientResponse, error) + Info(context.Context, *InfoRequest) (*InfoResponse, error) +} + +// RegisterProofApiServiceServer registers an implementation with a gRPC server. +func RegisterProofApiServiceServer(s grpc.ServiceRegistrar, srv ProofApiServiceServer) { + s.RegisterService(&ProofApiService_ServiceDesc, srv) +} + +func handlerRelayByTx(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(RelayByTxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProofApiServiceServer).RelayByTx(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/RelayByTx"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(ProofApiServiceServer).RelayByTx(ctx, req.(*RelayByTxRequest)) + }) +} + +func handlerCreateClient(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(CreateClientRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProofApiServiceServer).CreateClient(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/CreateClient"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(ProofApiServiceServer).CreateClient(ctx, req.(*CreateClientRequest)) + }) +} + +func handlerUpdateClient(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(UpdateClientRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProofApiServiceServer).UpdateClient(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/UpdateClient"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(ProofApiServiceServer).UpdateClient(ctx, req.(*UpdateClientRequest)) + }) +} + +func handlerInfo(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(InfoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProofApiServiceServer).Info(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/Info"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(ProofApiServiceServer).Info(ctx, req.(*InfoRequest)) + }) +} + +// ProofApiService_ServiceDesc is the grpc.ServiceDesc for ProofApiService. +var ProofApiService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: serviceName, + HandlerType: (*ProofApiServiceServer)(nil), + Methods: []grpc.MethodDesc{ + {MethodName: "RelayByTx", Handler: handlerRelayByTx}, + {MethodName: "CreateClient", Handler: handlerCreateClient}, + {MethodName: "UpdateClient", Handler: handlerUpdateClient}, + {MethodName: "Info", Handler: handlerInfo}, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proofapi/proofapi.proto", +} diff --git a/x/qbftclient/prover/relaytx/relaytx.go b/x/qbftclient/prover/relaytx/relaytx.go new file mode 100644 index 00000000..caf60ab9 --- /dev/null +++ b/x/qbftclient/prover/relaytx/relaytx.go @@ -0,0 +1,88 @@ +// Package relaytx assembles the unsigned message sequence that delivers proven +// Besu state to cbdc-node: the MsgUpdateClient chain that makes the proof +// height trusted, followed by the packet message the proof supports. +// +// It exists so cmd/qbftrelay (the one-shot CLI) and cmd/qbftproofapi (the +// service cosmos/ibc-relayer calls) share one implementation instead of two +// drifting copies. It combines prover (which stays free of ibc-go) with msgs +// (which stays free of the prover), which is why it is its own package rather +// than an addition to either. +package relaytx + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/cosmos-sdk/codec" + 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/x/qbftclient/prover" + "github.com/peersyst/cbdc-node/x/qbftclient/prover/msgs" + "github.com/peersyst/cbdc-node/x/qbftclient/types" +) + +// UpdateMsgs builds the MsgUpdateClient chain that advances clientID from +// trusted to target. +// +// The set the client trusts is the set carried by the header it last verified, +// so it is read from the counterparty rather than passed in — it cannot +// disagree with the chain. +func UpdateMsgs( + ctx context.Context, + chain prover.ChainReader, + contract common.Address, + clientID string, + trusted, target uint64, + signer string, +) ([]sdk.Msg, error) { + trustedHeader, err := chain.HeaderByNumber(ctx, trusted) + if err != nil { + return nil, fmt.Errorf("relaytx: fetching trusted header %d: %w", trusted, err) + } + trustedSet, err := types.HeaderValidators(trustedHeader) + if err != nil { + return nil, err + } + headers, err := prover.New(chain, contract).UpdateChain(ctx, trustedSet, trusted, target) + if err != nil { + return nil, err + } + return msgs.UpdateClientChain(clientID, headers, signer) +} + +// RecvMsgs builds one MsgRecvPacket per packet, each proved out of the +// counterparty contract's storage at target. +// +// target must be a height the client is (about to be) updated to: every proof +// is verified against that consensus state's storage root, so callers pair +// this with UpdateMsgs to the same height, updates first. +func RecvMsgs( + ctx context.Context, + chain prover.ChainReader, + contract common.Address, + cdc codec.BinaryCodec, + packets []channeltypesv2.Packet, + target uint64, + signer string, +) ([]sdk.Msg, error) { + p := prover.New(chain, contract) + out := make([]sdk.Msg, 0, len(packets)) + for _, packet := range packets { + // The commitment lives in the SOURCE client's store — the sender wrote + // it — which on this leg is the counterparty's client for cbdc-node. + proof, err := p.PacketCommitmentProof(ctx, packet.SourceClient, packet.Sequence, target) + if err != nil { + return nil, fmt.Errorf("relaytx: proving packet %d: %w", packet.Sequence, err) + } + msg, err := msgs.RecvPacket(cdc, packet, proof, target, signer) + if err != nil { + return nil, err + } + out = append(out, msg) + } + return out, nil +} From 1e5dd76449e84227031f880dea1225896a13e291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 6 Aug 2026 11:38:21 +0200 Subject: [PATCH 32/61] docs(corridor): a deploy script for the Besu side, and the inputs it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous corridor deployment lived in a session scratchpad and was lost with it, so the parameters had to be rediscovered from source. This puts both the script and the findings in the repo. contracts/spoke/DeployCorridorHub.s.sol is derived from upstream's 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 reverts on address(0). Two things cost a failed run to learn: - The custom-id addClient overload CANNOT produce the client-N naming the corridor already uses. validateCustomIBCIdentifier rejects any id beginning with "client-" or "channel-" — reserved for generated ids — so addClient("client-0", ...) reverts IBCInvalidClientId. The devnet's client-1 and client-2 came from the auto-id overload, which is what the script calls. - A failed forge script run is safe. It collects broadcasts from a local run before sending any, so the revert above left all three addresses holding empty code. addClient's irreversibility bites only once a run completes — worth knowing before this is pointed at a real spoke. docs/corridor-deploy-inputs.md carries what is verified and nothing else: which eureka revision to use (only two of five carry AttestationLightClient, and all five report version 1.0.0, so select by revision), the constructor and addClient signatures, a build recipe that works around npm being unable to clone any GitHub dependency here, and the two rig traps — local-node.sh's rm -rf $HOMEDIR and up.sh regenerating genesis on every run. Verified on a local rig, read back from chain rather than from the script log: getCounterparty("client-0") returns ("qbftclient-0", [0x]) — the single empty merkle-prefix element, which is the irreversible one. The attestor there is the Besu dev account, chosen to prove the deployment. A real corridor needs cbdc-node validator keys, and since the set is fixed in the constructor with no rotation, changing it is a redeploy. Co-Authored-By: Claude Opus 5 (1M context) --- contracts/spoke/DeployCorridorHub.s.sol | 115 ++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 contracts/spoke/DeployCorridorHub.s.sol diff --git a/contracts/spoke/DeployCorridorHub.s.sol b/contracts/spoke/DeployCorridorHub.s.sol new file mode 100644 index 00000000..eac095cf --- /dev/null +++ b/contracts/spoke/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); + } +} From f3cda6b2a49319b83d2756468e0305628cb8bb0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 6 Aug 2026 12:33:56 +0200 Subject: [PATCH 33/61] feat(corridor): relay packets automatically in both directions qbftproofapi constructs proofs and, by DEC-7, deliberately does not watch, retry or keep state. Until now that meant every leg was driven by hand: a send sat committed until an operator ran grpcurl and then cast or cbdcd. This is the piece that closes the loop. Per direction it polls, delivers, and then relays the ack -- because escrow release and commitment clearing are separate events, and a delivery whose ack never lands leaves the commitment open even though the money has moved. Two asymmetries between the directions are not incidental and are handled explicitly: - Outbound returns ICS26Router calldata for an ordinary EVM transaction. Inbound returns a bare cosmos.tx.v1beta1.TxBody, which is not signable: it must be wrapped in a TxRaw, decoded, given a fee, then signed. - cast send waits for a receipt; cbdcd tx broadcast in sync mode returns after CheckTx, when the transaction is in the mempool and NOT in a block. The ack proof needs the recv committed and indexed, so asking immediately lost a race the Besu side never has. Found by running it: the first BR->HN ack failed, and the same request succeeded seconds later. submit_to_cbdc now waits for the commit before returning. The high-water marks advance only after a leg completes, and the queries are strictly greater-than, so nothing looks backwards on its own. Recovering a stuck packet means lowering the mark files by hand -- deliberate, because a mark that advanced past a failure would hide it permanently. A failed ack is reported and the mark still advances: the money has moved, and re-delivering is not the remedy. The warning names the sequence so the ack can be relayed on its own, which was verified against the packet this bug stranded. Verified on the rig, hands off after the send: 777 axrp out and back, four legs relayed with no intervention, escrow and voucher supply both zero afterwards, sender's balance back to the same integer, no commitments open on either side. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/corridor/relay-watcher.sh | 183 ++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 scripts/corridor/relay-watcher.sh diff --git a/scripts/corridor/relay-watcher.sh b/scripts/corridor/relay-watcher.sh new file mode 100755 index 00000000..d383a174 --- /dev/null +++ b/scripts/corridor/relay-watcher.sh @@ -0,0 +1,183 @@ +#!/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 qbftattestor, which +# signs only what it has independently verified. +# +# 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}" +CBDCD="${CBDCD:-bin/cbdcd}" +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 +) + "$CBDCD" 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; } + "$CBDCD" 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=$("$CBDCD" 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 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 From dd312e9b07d939c7a1897826640df309fdb992f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 6 Aug 2026 14:48:26 +0200 Subject: [PATCH 34/61] feat(corridor): configuration for cosmos/ibc-relayer against the Besu corridor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qbftproofapi exists as a proof-API shim so this relayer can drive the corridor, so the relayer — not a hand-rolled watcher — is the intended component. This is the configuration it needs, plus what was learned trying to run it. The relayer starts clean against this config and its DELIVERY leg works: given a tx hash it built the proof through qbftproofapi and delivered on Besu. Two things stop it being usable as-is, both recorded here rather than in a chat log: - It has NO event loop. Nothing was relayed until Relay(tx_hash, chain_id) was called on its gRPC API; a send alone sits untouched. Whatever runs it must feed it hashes. - Its Cosmos signer derives a cosmos-style bech32 from the key, while cbdc-node is an Ethermint chain deriving eth_secp256k1 addresses. The ack leg fails as a result. Whether the relayer can sign for an Ethermint chain at all is a question for its owners; nothing here can work around it. A SEPARATE database, deliberately. The relayer dedupes on (client, sequence), and the shared one still holds rows from the retired devnet — including qbftclient-0 rows whose sequences a re-genesised chain restarts through, so new packets would collide with them and be silently never relayed. relayer-keys.json is gitignored and only the template is committed. The template carries the rule that cost real debugging twice: the relayer needs its OWN account per chain per leg. Sharing one with the sender, or between two legs, causes sequence contention that drops acknowledgements while the packet has already been delivered. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + scripts/corridor/relayer-config.yml | 104 +++++++++++++++++++++ scripts/corridor/relayer-keys.example.json | 27 ++++++ 3 files changed, 134 insertions(+) create mode 100644 scripts/corridor/relayer-config.yml create mode 100644 scripts/corridor/relayer-keys.example.json diff --git a/.gitignore b/.gitignore index 1e06fafb..79d72151 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ release/ bin/ .claude/ + +# relayer signing keys — never commit +scripts/corridor/relayer-keys.json diff --git a/scripts/corridor/relayer-config.yml b/scripts/corridor/relayer-config.yml new file mode 100644 index 00000000..a67527ed --- /dev/null +++ b/scripts/corridor/relayer-config.yml @@ -0,0 +1,104 @@ +# 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. +# +# Run: +# bin/relayer --config scripts/corridor/relayer-config.yml +# with qbftattestor and qbftproofapi already up. + +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 -- see + # relayer-keys.example.json. + keys_path: 'scripts/corridor/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 00000000..c5f5ae01 --- /dev/null +++ b/scripts/corridor/relayer-keys.example.json @@ -0,0 +1,27 @@ +{ + "_comment": [ + "Template for the relayer's signing keys. Copy to relayer-keys.json and fill in.", + "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/cbdcd keys unsafe-export-eth-key relayer --keyring-backend test --home .cbdcd-rig", + "", + "relayer-keys.json is gitignored. Never commit a filled-in copy." + ], + "cbdc_1449999-1": { + "name": "Honduras (cbdc-node) relayer", + "address": "ethm1skav49ayhurfxcudzw8ghaatshtj3zcp3mpha0", + "private_key": "" + }, + "1337": { + "name": "Brazil (Besu) relayer", + "address": "0xfe3b557e8fb62b89f4916b721be55ceb828dbd73", + "private_key": "" + } +} From 7c36924a2fbd4a5f04089a0095dfdbcb96b7e128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 6 Aug 2026 16:04:09 +0200 Subject: [PATCH 35/61] feat(corridor): one command to bring up a leg, refusing the mistakes we made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standing up a corridor leg means getting four things right that fail silently or irreversibly. Every one of them was got wrong at least once this week, so this script refuses to proceed rather than producing a corridor that looks fine and is not. Guard 1 — attestor key uniqueness. The signed attestation payload carries no domain separation: no chain id, no client id, no verifying contract (x/qbftclient/attestor/attestation.go:23-40). A signature is valid against ANY AttestationLightClient holding the key, so reuse across corridors makes attestations cross-replay — and since two chains disagree on the timestamp at a given height, a cross-replayed state attestation is a PERMANENT freeze. The attestor's own guard cannot help: it is a durable log under -state-dir, so two processes sharing a key share no guard at all. Verified against a rig that was already violating this, with the same key on both legs. Guard 2 — one relayer account per leg. Two processes signing from one account collide on the sequence number and the loser is dropped silently. Seen twice: sender vs relayer, then leg vs leg. Both times a packet was DELIVERED and its ack lost, leaving a commitment open behind money that had already moved — which is exactly the state an escrow-only monitor reports as settled. Guard 3 — signer derivation. cbdc-node accepts plain secp256k1 alongside its own eth_secp256k1 (devnet-findings §3.6), so cosmos/ibc-relayer needs no port. But qbftproofapi stamps -signer into Msg.Signer and the SDK requires msg-signer == tx-signer; hand it an eth-derived address while the relayer signs cosmos-derived and every ack fails, looking exactly like a curve incompatibility. It is not — that misreading cost most of a day. Guard 4 — process identity, not liveness. The first version grepped the log for "listening on", which the attestor prints BEFORE the bind can fail; the second probed the port, which succeeds against whichever leg already owns it. Both pass while the new leg relays through another leg's attestor, keyed for a different light client. It now compares the address the port reports against the address this leg's key derives, and refuses on mismatch. Caught by testing the guard rather than trusting it. The counterparty prefix asymmetry is applied rather than documented: a single empty element on Besu, two elements on Cosmos, both irreversible. Contract deployment stays out of scope — that is DeployCorridorHub.s.sol against a writable eureka checkout, whose build recipe is in corridor-deploy-inputs.md. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/corridor/up-corridor.sh | 181 ++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100755 scripts/corridor/up-corridor.sh diff --git a/scripts/corridor/up-corridor.sh b/scripts/corridor/up-corridor.sh new file mode 100755 index 00000000..893d9a45 --- /dev/null +++ b/scripts/corridor/up-corridor.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Bring up one corridor leg: cbdc-node <-> a Besu/QBFT chain. +# +# Every step below has an irreversible or silent-failure trap behind it, and all +# of them were hit at least once getting here. The point of this script is that +# they are hit zero times again -- it refuses to proceed rather than producing a +# corridor that looks fine and is not. +# +# What it does NOT do: deploy the contracts. That is +# contracts/spoke/DeployCorridorHub.s.sol, run from a writable eureka checkout -- +# see docs/corridor-deploy-inputs.md for the build recipe, which is not obvious. +# Pass the addresses it printed in via env. +# +# Usage: +# ROUTER=0x… TRANSFER=0x… LIGHT_CLIENT=0x… BESU_RPC=… BESU_CHAIN=… \ +# CBDC_CLIENT=qbftclient-0 BESU_CLIENT=client-0 \ +# ATTESTOR_KEY= RELAYER_KEY_NAME=relayer0 \ +# scripts/corridor/up-corridor.sh +set -uo pipefail + +CBDC_RPC="${CBDC_RPC:-http://127.0.0.1:26657}" +CBDC_CHAIN="${CBDC_CHAIN:-cbdc_1449999-1}" +CBDC_HOME="${CBDC_HOME:?cbdc-node home required}" +CBDCD="${CBDCD:-bin/cbdcd}" +BESU_RPC="${BESU_RPC:?Besu JSON-RPC required}" +BESU_CHAIN="${BESU_CHAIN:?Besu EVM chain id required}" +ROUTER="${ROUTER:?ICS26Router address required}" +LIGHT_CLIENT="${LIGHT_CLIENT:?AttestationLightClient address required}" +CBDC_CLIENT="${CBDC_CLIENT:?cbdc-node client id required}" +BESU_CLIENT="${BESU_CLIENT:?Besu client id required}" +ATTESTOR_KEY="${ATTESTOR_KEY:?attestor secp256k1 key required, hex without 0x}" +RELAYER_KEY_NAME="${RELAYER_KEY_NAME:?cbdc-node keyring name of the relayer for THIS leg}" +STATE_ROOT="${STATE_ROOT:-$PWD/.corridor}" + +ATTESTOR_HTTP="${ATTESTOR_HTTP:-127.0.0.1:8090}" +ATTESTOR_GRPC="${ATTESTOR_GRPC:-127.0.0.1:8091}" +PROOF_API="${PROOF_API:-127.0.0.1:8888}" + +LEG="$BESU_CHAIN-$BESU_CLIENT" +LEG_DIR="$STATE_ROOT/$LEG" +REGISTRY="$STATE_ROOT/attestor-keys.tsv" +mkdir -p "$LEG_DIR" && touch "$REGISTRY" + +die() { echo "ERROR: $*" >&2; exit 1; } +ok() { echo " ok $*"; } + +# ── Guard 1: attestor key uniqueness ────────────────────────────────────────── +# The signed attestation payload carries NO domain separation -- no chain id, no +# client id, no verifying contract (x/qbftclient/attestor/attestation.go:23-40). +# A signature is therefore valid against ANY AttestationLightClient whose set +# holds the key. Reusing a key across corridors makes attestations cross-replay, +# and because two chains disagree on the timestamp at a given height, a +# cross-replayed state attestation is a PERMANENT FREEZE with no unfreeze. +# +# The attestor's own freeze guard cannot save you here: it is a durable log under +# -state-dir, so two processes sharing a key have no shared guard at all. +KEY_FP=$(printf '%s' "$ATTESTOR_KEY" | sha256sum | cut -c1-16) +if prior=$(grep -P "^$KEY_FP\t" "$REGISTRY" 2>/dev/null | head -1); then + prior_leg=$(echo "$prior" | cut -f2) + [ "$prior_leg" = "$LEG" ] || die "attestor key already bound to leg '$prior_leg'. + Every (key, client) pair must be unique per corridor AND per deployment. + Generate a fresh key: cast wallet new + Then redeploy this leg's AttestationLightClient with it -- the attestor set is + fixed in the constructor, so re-keying means a new client id, and a new client + id means new voucher denoms on this leg. Cheap now; a migration later." +fi + +# ── Guard 2: the relayer needs its OWN account, per leg ─────────────────────── +# Two processes signing cbdc-node txs from one account collide on the sequence +# number, and the loser is silently dropped. Observed twice: once between the +# sender and the relayer, once between two legs' relayers. Both times a packet +# was DELIVERED and its acknowledgement lost, leaving the commitment open behind +# money that had already moved -- the exact state an escrow-only monitor calls +# settled. +RELAYER_ADDR=$("$CBDCD" keys show "$RELAYER_KEY_NAME" -a --keyring-backend test --home "$CBDC_HOME" 2>/dev/null) \ + || die "no key '$RELAYER_KEY_NAME' in the keyring. Create one PER LEG: + $CBDCD keys add $RELAYER_KEY_NAME --algo secp256k1 --keyring-backend test --home $CBDC_HOME" +if used=$(grep -P "\t$RELAYER_ADDR$" "$REGISTRY" 2>/dev/null | grep -v -P "\t$LEG\t" | head -1); then + die "relayer account $RELAYER_ADDR is already used by leg '$(echo "$used" | cut -f2)'." +fi + +# ── Guard 3: the relayer's key algorithm, and the signer address it implies ─── +# cbdc-node accepts plain secp256k1 alongside its own eth_secp256k1 +# (docs/ibc-v2-devnet-findings.md §3.6), so cosmos/ibc-relayer needs no port. +# But qbftproofapi stamps -signer into Msg.Signer, and the SDK requires +# msg-signer == tx-signer. Give it an eth-derived address while the relayer +# signs from a cosmos-derived one and every ack fails, looking like a curve +# incompatibility. It is not. +case "$("$CBDCD" keys show "$RELAYER_KEY_NAME" --keyring-backend test --home "$CBDC_HOME" --output json 2>/dev/null | grep -o '"[a-z_0-9]*secp256k1"' | head -1)" in + *eth_secp256k1*) echo " warn attestor/relayer key '$RELAYER_KEY_NAME' is eth_secp256k1. + That is fine for the watcher, but cosmos/ibc-relayer derives its address + with cosmos rules, so -signer below must be the address IT derives, not + this one. Prefer --algo secp256k1 for a relayer account." ;; +esac +ok "relayer account for this leg: $RELAYER_ADDR" + +# ── Counterparty registration, with the asymmetry spelled out ──────────────── +if "$CBDCD" query ibc client counterparty-info "$CBDC_CLIENT" --node "$CBDC_RPC" >/dev/null 2>&1; then + ok "counterparty already registered on $CBDC_CLIENT" +else + # 🔴 The two sides take DIFFERENT prefixes and both are irreversible. + # Besu: a single EMPTY element [0x] (set by DeployCorridorHub) + # Cosmos: two elements ["aWJj", ""] (below) + # Registering ["ibc",""] on Besu yields InvalidPathLength(1,2) and the + # client is unrecoverable. + "$CBDCD" tx ibc client add-counterparty "$CBDC_CLIENT" "$BESU_CLIENT" "aWJj" "" \ + --from "$RELAYER_KEY_NAME" --keyring-backend test --home "$CBDC_HOME" \ + --chain-id "$CBDC_CHAIN" --node "$CBDC_RPC" --gas 400000 --yes >/dev/null 2>&1 \ + || die "add-counterparty failed" + sleep 6 + "$CBDCD" query ibc client counterparty-info "$CBDC_CLIENT" --node "$CBDC_RPC" >/dev/null 2>&1 \ + || die "add-counterparty did not take" + ok "counterparty registered: $CBDC_CLIENT -> $BESU_CLIENT" +fi + +# ── Processes ───────────────────────────────────────────────────────────────── +mkdir -p "$LEG_DIR/attestor-state" +nohup bin/qbftattestor \ + -cbdc-rpc "$CBDC_RPC" -listen "$ATTESTOR_HTTP" -grpc "$ATTESTOR_GRPC" \ + -client-id "$CBDC_CLIENT" -key "$ATTESTOR_KEY" -cbdc-chain-id "$CBDC_CHAIN" \ + -light-client "$LIGHT_CLIENT" -besu-chain-id "$BESU_CHAIN" \ + -state-dir "$LEG_DIR/attestor-state" > "$LEG_DIR/attestor.log" 2>&1 & +sleep 4 +# Probe the socket, do not trust the log. The attestor prints "listening on" +# BEFORE the bind can fail, so a log grep happily reports success while the +# process is dying on an address already in use -- which is precisely how a +# second leg silently attaches itself to the first leg's attestor. +ATTESTOR_ADDR=$(curl -s -m 3 "http://$ATTESTOR_HTTP/address" | grep -oE '0x[a-fA-F0-9]{40}' | head -1) +[ -n "$ATTESTOR_ADDR" ] \ + || { tail -5 "$LEG_DIR/attestor.log"; die "attestor is not answering on $ATTESTOR_HTTP"; } + +# Liveness is not enough: something answering this port proves only that SOME +# attestor is there. If the bind failed because another leg already holds the +# port, the probe succeeds against THAT leg -- and this corridor would then relay +# through an attestor keyed for a different light client. Check identity. +EXPECT_ADDR=$(cast wallet address --private-key "0x$ATTESTOR_KEY" 2>/dev/null) +if [ -n "$EXPECT_ADDR" ] && [ "${ATTESTOR_ADDR,,}" != "${EXPECT_ADDR,,}" ]; then + tail -5 "$LEG_DIR/attestor.log" + die "$ATTESTOR_HTTP is answering as $ATTESTOR_ADDR, but this leg's key is $EXPECT_ADDR. + Another leg already holds that port. Give this leg its own ATTESTOR_HTTP / + ATTESTOR_GRPC / PROOF_API, or it will relay through the wrong attestor." +fi +ok "attestor up, signing as $ATTESTOR_ADDR" + +nohup bin/qbftproofapi \ + -listen "$PROOF_API" -besu-rpc "$BESU_RPC" -cbdc-rpc "$CBDC_RPC" \ + -attestor-grpc "$ATTESTOR_GRPC" -attestor-http "http://$ATTESTOR_HTTP" \ + -router "$ROUTER" -cbdc-chain-id "$CBDC_CHAIN" -besu-chain-id "$BESU_CHAIN" \ + -cbdc-client "$CBDC_CLIENT" -besu-client "$BESU_CLIENT" \ + -evm-chain-id "${EVM_CHAIN_ID:-1449999}" -signer "$RELAYER_ADDR" \ + > "$LEG_DIR/proofapi.log" 2>&1 & +sleep 4 +# Same reasoning: probe, do not grep. A bind failure here is worse than obvious, +# because the leg would relay through ANOTHER leg's proof api -- built for a +# different chain id and client id, so every proof it returns is for the wrong +# corridor. +grpcurl -plaintext -max-time 3 "$PROOF_API" list >/dev/null 2>&1 \ + || { tail -5 "$LEG_DIR/proofapi.log"; die "proof api is not answering on $PROOF_API"; } +ok "proof api up, signing msgs as $RELAYER_ADDR" + +printf '%s\t%s\t%s\n' "$KEY_FP" "$LEG" "$RELAYER_ADDR" >> "$REGISTRY" + +cat < $BESU_CHAIN + cbdc client $CBDC_CLIENT besu client $BESU_CLIENT + attestor http://$ATTESTOR_HTTP (grpc $ATTESTOR_GRPC) + proof api $PROOF_API + state $LEG_DIR + +Relaying is NOT running. Choose one: + cosmos/ibc-relayer the intended component. Needs a hash fed to + Relay(tx_hash, chain_id) -- it has no event loop by design. + relay-watcher.sh stopgap that watches both chains. Drives recv+ack only; + it does NOT build timeouts, so a packet nobody can deliver + leaves escrow open until someone drives the timeout by hand. + +Senders MUST set encoding "application/x-solidity-abi" and an ABSOLUTE timeout in +unix SECONDS (<=24h). No CLI sets the encoding; a blank one is delivered, +proof-verified, then silently rejected with no voucher minted. +EOF From 4a390fc9c154debf4edbc6913b6f893e51c58162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 6 Aug 2026 16:34:07 +0200 Subject: [PATCH 36/61] fix(corridor): make the deploy guards correct on their own terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the guards added yesterday. Two of the three were right only by accident, which for code whose whole job is refusing bad input is the same as being wrong. The attestor-key guard tested the wrong exit status. `grep ... | head` reports HEAD's status, and head exits 0 on empty input, so the branch was taken even when grep matched nothing — on a fresh registry prior_leg is empty, the equality fails and the guard dies with "already bound to leg ''". It would have blocked every FIRST deployment. It happened to work only because `pipefail` is set forty lines earlier; verified by running the same construct with pipefail off, where it misfires immediately. Now decided by awk on the field itself, so no shell option is load-bearing. Both registry lookups also used `grep -P`. PCRE is a GNU extension and absent on macOS and busybox, so the guards would have degraded into silence off Linux — worse than not having them, because the operator would believe they ran. Now awk, which also removes the tab-in-a-pattern quoting. The watcher's log parser did arithmetic on unvalidated fields: one malformed entry and `$((16#))` aborted the loop, taking every other packet in that batch with it. Skips the entry instead. Verified by making each guard fire and, for the first, by making it NOT fire on a fresh registry with pipefail explicitly disabled. Nothing here changes what the guards are for; it changes whether they work when the surrounding conditions are not exactly the ones I happened to test under. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/corridor/relay-watcher.sh | 8 +++++++- scripts/corridor/up-corridor.sh | 20 ++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/corridor/relay-watcher.sh b/scripts/corridor/relay-watcher.sh index d383a174..cc967c1e 100755 --- a/scripts/corridor/relay-watcher.sh +++ b/scripts/corridor/relay-watcher.sh @@ -171,7 +171,13 @@ poll_besu_to_cbdc() { 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 echo "$((16#$b)) $h $((16#$s))"; done) + | 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" diff --git a/scripts/corridor/up-corridor.sh b/scripts/corridor/up-corridor.sh index 893d9a45..c8fa7a73 100755 --- a/scripts/corridor/up-corridor.sh +++ b/scripts/corridor/up-corridor.sh @@ -55,8 +55,14 @@ ok() { echo " ok $*"; } # The attestor's own freeze guard cannot save you here: it is a durable log under # -state-dir, so two processes sharing a key have no shared guard at all. KEY_FP=$(printf '%s' "$ATTESTOR_KEY" | sha256sum | cut -c1-16) -if prior=$(grep -P "^$KEY_FP\t" "$REGISTRY" 2>/dev/null | head -1); then - prior_leg=$(echo "$prior" | cut -f2) +# Match on grep's own status, not a pipeline's. `grep ... | head` reports HEAD's +# status, which is 0 even when grep matched nothing, so the branch would be taken +# on an empty registry and this guard would block every first deployment. It +# happens to work above only because `pipefail` is set 40 lines up -- too subtle +# to leave load-bearing. +prior=$(awk -F'\t' -v fp="$KEY_FP" '$1 == fp { print; exit }' "$REGISTRY" 2>/dev/null) +if [ -n "$prior" ]; then + prior_leg=$(printf '%s' "$prior" | cut -f2) [ "$prior_leg" = "$LEG" ] || die "attestor key already bound to leg '$prior_leg'. Every (key, client) pair must be unique per corridor AND per deployment. Generate a fresh key: cast wallet new @@ -75,8 +81,14 @@ fi RELAYER_ADDR=$("$CBDCD" keys show "$RELAYER_KEY_NAME" -a --keyring-backend test --home "$CBDC_HOME" 2>/dev/null) \ || die "no key '$RELAYER_KEY_NAME' in the keyring. Create one PER LEG: $CBDCD keys add $RELAYER_KEY_NAME --algo secp256k1 --keyring-backend test --home $CBDC_HOME" -if used=$(grep -P "\t$RELAYER_ADDR$" "$REGISTRY" 2>/dev/null | grep -v -P "\t$LEG\t" | head -1); then - die "relayer account $RELAYER_ADDR is already used by leg '$(echo "$used" | cut -f2)'." +# awk rather than grep -P: PCRE is a GNU extension, absent on macOS and busybox, +# and a guard that silently stops working off-Linux is worse than none. +used_leg=$(awk -F'\t' -v addr="$RELAYER_ADDR" -v leg="$LEG" \ + '$3 == addr && $2 != leg { print $2; exit }' "$REGISTRY" 2>/dev/null) +if [ -n "$used_leg" ]; then + die "relayer account $RELAYER_ADDR is already used by leg '$used_leg'. + Each leg needs its own account, or their transactions collide on the sequence + number and the loser is dropped without an error." fi # ── Guard 3: the relayer's key algorithm, and the signer address it implies ─── From b0debe4beda75c445c65dd3350475ec5fa62cfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 6 Aug 2026 16:42:43 +0200 Subject: [PATCH 37/61] refactor(corridor): put the deploy script where it belongs, and drop session narrative from comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural review of my own commits, on placement and comments rather than behaviour. Nothing here changes what any of it does. DeployCorridorHub.s.sol was in contracts/spoke/, whose README defines that directory as "Solidity we author for the Besu spoke" — contracts that are deployed as part of the system. A Foundry deploy script is not one: it is never deployed, and it cannot even build there, since that directory has no Foundry project and the script only compiles inside a writable eureka checkout. Anyone opening contracts/spoke/ would reasonably read it as part of the contract set. Moved to scripts/corridor/ alongside the tooling that uses it, with the two references updated. Comments in both scripts narrated how the traps were found — "hit at least once getting here", "observed twice: once between the sender and the relayer, once between two legs". The rule belongs in the source; the history belongs in git, where it already is. Rewritten to state the failure and its consequence without the first person. The comments stay long: this repo explains why, and these particular whys are the difference between a working corridor and a bricked one. Test files renamed to the convention the package already uses — ics20_abi_test.go names the file under test plus the aspect, so denom_route_test.go becomes ics02_denom_route_test.go and nonce_serialisation_test.go becomes ics20_nonce_test.go. Neither was findable from the file it tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../corridor}/DeployCorridorHub.s.sol | 0 scripts/corridor/up-corridor.sh | 28 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) rename {contracts/spoke => scripts/corridor}/DeployCorridorHub.s.sol (100%) diff --git a/contracts/spoke/DeployCorridorHub.s.sol b/scripts/corridor/DeployCorridorHub.s.sol similarity index 100% rename from contracts/spoke/DeployCorridorHub.s.sol rename to scripts/corridor/DeployCorridorHub.s.sol diff --git a/scripts/corridor/up-corridor.sh b/scripts/corridor/up-corridor.sh index c8fa7a73..b520e9d7 100755 --- a/scripts/corridor/up-corridor.sh +++ b/scripts/corridor/up-corridor.sh @@ -1,13 +1,12 @@ #!/usr/bin/env bash # Bring up one corridor leg: cbdc-node <-> a Besu/QBFT chain. # -# Every step below has an irreversible or silent-failure trap behind it, and all -# of them were hit at least once getting here. The point of this script is that -# they are hit zero times again -- it refuses to proceed rather than producing a -# corridor that looks fine and is not. +# Each step below sits in front of a trap that is either irreversible or silent. +# The script refuses to proceed rather than producing a corridor that looks fine +# and is not. # # What it does NOT do: deploy the contracts. That is -# contracts/spoke/DeployCorridorHub.s.sol, run from a writable eureka checkout -- +# scripts/corridor/DeployCorridorHub.s.sol, run from a writable eureka checkout -- # see docs/corridor-deploy-inputs.md for the build recipe, which is not obvious. # Pass the addresses it printed in via env. # @@ -55,11 +54,11 @@ ok() { echo " ok $*"; } # The attestor's own freeze guard cannot save you here: it is a durable log under # -state-dir, so two processes sharing a key have no shared guard at all. KEY_FP=$(printf '%s' "$ATTESTOR_KEY" | sha256sum | cut -c1-16) -# Match on grep's own status, not a pipeline's. `grep ... | head` reports HEAD's -# status, which is 0 even when grep matched nothing, so the branch would be taken -# on an empty registry and this guard would block every first deployment. It -# happens to work above only because `pipefail` is set 40 lines up -- too subtle -# to leave load-bearing. +# Decided on the field, not on a pipeline's exit status: `grep ... | head` reports +# HEAD's status, which is 0 even when grep matched nothing, so an empty registry +# would take the branch and block every first deployment. Whether that construct +# works depends on `pipefail` being set far above, which is too subtle to leave +# load-bearing. prior=$(awk -F'\t' -v fp="$KEY_FP" '$1 == fp { print; exit }' "$REGISTRY" 2>/dev/null) if [ -n "$prior" ]; then prior_leg=$(printf '%s' "$prior" | cut -f2) @@ -73,11 +72,10 @@ fi # ── Guard 2: the relayer needs its OWN account, per leg ─────────────────────── # Two processes signing cbdc-node txs from one account collide on the sequence -# number, and the loser is silently dropped. Observed twice: once between the -# sender and the relayer, once between two legs' relayers. Both times a packet -# was DELIVERED and its acknowledgement lost, leaving the commitment open behind -# money that had already moved -- the exact state an escrow-only monitor calls -# settled. +# number and the loser is dropped without an error. The usual casualty is an +# acknowledgement whose packet was already delivered, which leaves the commitment +# open behind money that has moved -- the exact state an escrow-only monitor +# reports as settled. RELAYER_ADDR=$("$CBDCD" keys show "$RELAYER_KEY_NAME" -a --keyring-backend test --home "$CBDC_HOME" 2>/dev/null) \ || die "no key '$RELAYER_KEY_NAME' in the keyring. Create one PER LEG: $CBDCD keys add $RELAYER_KEY_NAME --algo secp256k1 --keyring-backend test --home $CBDC_HOME" From f3f545e19b4c5881c202b1b17d70aa6e4a7e8bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 7 Aug 2026 14:56:23 +0200 Subject: [PATCH 38/61] feat(cbdc): gov-gated MsgSetDenomMetadata, with a display guard the SDK lacks ibc-go synthesises voucher metadata from the denom string alone and only writes it when none exists yet, so a voucher that arrives before its metadata is seeded is stuck at 0 decimals forever. This is the post-genesis correction path, implementing DEC-22. Gated on the gov authority rather than the mint/burn owner: how a currency presents itself is a monetary-presentation decision that should take a proposal, not an operational key. ValidateMetadataDisplayResolvable exists because x/bank's own Validate is not sufficient here. The erc20 precompile resolves decimals by matching Display against the denom units, but for an ibc/ base it matches only the LAST '/'-separated segment. x/bank requires a unit named after the FULL Display, so trace-qualified metadata passes SDK validation and still makes every decimals() call revert -- exactly the mislabelled voucher this message exists to fix, so it must not be able to write one. Co-Authored-By: Claude Opus 5 --- proto/cbdc/tx.proto | 21 + x/cbdc/keeper/msg_server.go | 25 + .../msg_server_set_denom_metadata_test.go | 112 +++++ x/cbdc/testutil/expected_keepers_mock.go | 13 + x/cbdc/types/codec.go | 4 + x/cbdc/types/errors.go | 15 +- x/cbdc/types/expected_keepers.go | 2 + x/cbdc/types/message_set_denom_metadata.go | 56 +++ x/cbdc/types/tx.pb.go | 456 ++++++++++++++++-- 9 files changed, 666 insertions(+), 38 deletions(-) create mode 100644 x/cbdc/keeper/msg_server_set_denom_metadata_test.go create mode 100644 x/cbdc/types/message_set_denom_metadata.go diff --git a/proto/cbdc/tx.proto b/proto/cbdc/tx.proto index 3fd25a0b..2bed5e0b 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/x/cbdc/keeper/msg_server.go b/x/cbdc/keeper/msg_server.go index 031a80c0..b2b7657e 100644 --- a/x/cbdc/keeper/msg_server.go +++ b/x/cbdc/keeper/msg_server.go @@ -73,3 +73,28 @@ func (k msgServer) UpdateParams(goCtx context.Context, msg *types.MsgUpdateParam return &types.MsgUpdateParamsResponse{}, nil } + +// SetDenomMetadata overwrites bank denom metadata. It exists because ibc-go +// synthesises voucher metadata from the denom string alone and only writes it +// when none exists yet, so a voucher that arrives before its metadata is +// seeded is stuck with 0 decimals forever — this is the post-genesis +// correction path. It is gated on the gov authority rather than the mint/burn +// owner: how a currency presents itself is a monetary-presentation decision +// that should take a proposal, not an operational key. +func (k msgServer) SetDenomMetadata(goCtx context.Context, msg *types.MsgSetDenomMetadata) (*types.MsgSetDenomMetadataResponse, error) { + if k.authority != msg.Authority { + return nil, errors.Wrapf(types.ErrUnauthorized, "expected %s got %s", k.authority, msg.Authority) + } + + if err := msg.Metadata.Validate(); err != nil { + return nil, errors.Wrap(types.ErrInvalidMetadata, err.Error()) + } + if err := types.ValidateMetadataDisplayResolvable(msg.Metadata); err != nil { + return nil, err + } + + ctx := sdk.UnwrapSDKContext(goCtx) + k.bk.SetDenomMetaData(ctx, msg.Metadata) + + return &types.MsgSetDenomMetadataResponse{}, nil +} diff --git a/x/cbdc/keeper/msg_server_set_denom_metadata_test.go b/x/cbdc/keeper/msg_server_set_denom_metadata_test.go new file mode 100644 index 00000000..4c273691 --- /dev/null +++ b/x/cbdc/keeper/msg_server_set_denom_metadata_test.go @@ -0,0 +1,112 @@ +package keeper + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/peersyst/cbdc-node/x/cbdc/testutil" + "github.com/peersyst/cbdc-node/x/cbdc/types" + "github.com/stretchr/testify/require" +) + +// testVoucherDenom is sha256("transfer/qbftclient-0/uusd") prefixed the way +// ibc-go derives voucher denoms; the exact hash is irrelevant here, only the +// ibc/ prefix is, because it switches the display-resolution rule under test. +const testVoucherDenom = "ibc/6490A7EAB61059BFC1CDDEB05917DD70BDF3A611654162A1A47DB930D40D8AF4" + +func testVoucherMetadata() banktypes.Metadata { + return banktypes.Metadata{ + Description: "Voucher for tUSD received over transfer/qbftclient-0/uusd", + DenomUnits: []*banktypes.DenomUnit{ + {Denom: testVoucherDenom, Exponent: 0}, + {Denom: "uusd", Exponent: 6}, + }, + Base: testVoucherDenom, + Display: "uusd", + Name: "Test USD", + Symbol: "tUSD", + } +} + +func TestMsgServer_SetDenomMetadata(t *testing.T) { + blankName := testVoucherMetadata() + blankName.Name = "" + + // Passes banktypes Validate (display names a denom unit) but the erc20 + // precompile strips the trace and looks for a "uusd" unit, which is absent. + unresolvableDisplay := testVoucherMetadata() + unresolvableDisplay.Display = "transfer/qbftclient-0/uusd" + unresolvableDisplay.DenomUnits = []*banktypes.DenomUnit{ + {Denom: testVoucherDenom, Exponent: 0}, + {Denom: "transfer/qbftclient-0/uusd", Exponent: 6}, + } + require.NoError(t, unresolvableDisplay.Validate()) + + tt := []struct { + name string + authority string + metadata banktypes.Metadata + expectedErr error + errContains string + }{ + { + name: "should fail - unauthorized authority", + authority: testOwner, + metadata: testVoucherMetadata(), + expectedErr: types.ErrUnauthorized, + }, + { + name: "should fail - invalid metadata", + authority: testGovAuthority, + metadata: blankName, + expectedErr: types.ErrInvalidMetadata, + errContains: "name field cannot be blank", + }, + { + name: "should fail - display last segment has no denom unit", + authority: testGovAuthority, + metadata: unresolvableDisplay, + expectedErr: types.ErrInvalidMetadata, + errContains: "display denomination not found", + }, + { + name: "should pass - voucher metadata written", + authority: testGovAuthority, + metadata: testVoucherMetadata(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + var written *banktypes.Metadata + cbdcKeeper, ctx := setupCbdcKeeper(t, func(ctx sdk.Context, bankKeeper *testutil.MockBankKeeper) { + if tc.expectedErr == nil { + bankKeeper.EXPECT().SetDenomMetaData(ctx, tc.metadata). + Do(func(_ sdk.Context, m banktypes.Metadata) { written = &m }). + Times(1) + } + }) + msgServer := NewMsgServerImpl(*cbdcKeeper) + + msg := &types.MsgSetDenomMetadata{ + Authority: tc.authority, + Metadata: tc.metadata, + } + + _, err := msgServer.SetDenomMetadata(ctx, msg) + if tc.expectedErr != nil { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr.Error()) + if tc.errContains != "" { + require.Contains(t, err.Error(), tc.errContains) + } + return + } + + require.NoError(t, err) + require.NotNil(t, written) + require.Equal(t, tc.metadata, *written) + }) + } +} diff --git a/x/cbdc/testutil/expected_keepers_mock.go b/x/cbdc/testutil/expected_keepers_mock.go index c67005ac..38b61067 100644 --- a/x/cbdc/testutil/expected_keepers_mock.go +++ b/x/cbdc/testutil/expected_keepers_mock.go @@ -9,6 +9,7 @@ import ( reflect "reflect" types "github.com/cosmos/cosmos-sdk/types" + types0 "github.com/cosmos/cosmos-sdk/x/bank/types" gomock "github.com/golang/mock/gomock" ) @@ -169,3 +170,15 @@ func (mr *MockBankKeeperMockRecorder) SendCoinsFromModuleToAccount(ctx, senderMo mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendCoinsFromModuleToAccount", reflect.TypeOf((*MockBankKeeper)(nil).SendCoinsFromModuleToAccount), ctx, senderModule, recipientAddr, amt) } + +// SetDenomMetaData mocks base method. +func (m *MockBankKeeper) SetDenomMetaData(ctx context.Context, denomMetaData types0.Metadata) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetDenomMetaData", ctx, denomMetaData) +} + +// SetDenomMetaData indicates an expected call of SetDenomMetaData. +func (mr *MockBankKeeperMockRecorder) SetDenomMetaData(ctx, denomMetaData interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDenomMetaData", reflect.TypeOf((*MockBankKeeper)(nil).SetDenomMetaData), ctx, denomMetaData) +} diff --git a/x/cbdc/types/codec.go b/x/cbdc/types/codec.go index 3e2d58a5..48622c19 100644 --- a/x/cbdc/types/codec.go +++ b/x/cbdc/types/codec.go @@ -11,6 +11,7 @@ func RegisterCodec(cdc *codec.LegacyAmino) { cdc.RegisterConcrete(&MsgMint{}, "cbdc/Mint", nil) cdc.RegisterConcrete(&MsgBurn{}, "cbdc/Burn", nil) cdc.RegisterConcrete(&MsgUpdateParams{}, "cbdc/UpdateParams", nil) + cdc.RegisterConcrete(&MsgSetDenomMetadata{}, "cbdc/SetDenomMetadata", nil) } func RegisterInterfaces(registry cdctypes.InterfaceRegistry) { @@ -23,6 +24,9 @@ func RegisterInterfaces(registry cdctypes.InterfaceRegistry) { registry.RegisterImplementations((*sdk.Msg)(nil), &MsgUpdateParams{}, ) + registry.RegisterImplementations((*sdk.Msg)(nil), + &MsgSetDenomMetadata{}, + ) msgservice.RegisterMsgServiceDesc(registry, &_Msg_serviceDesc) } diff --git a/x/cbdc/types/errors.go b/x/cbdc/types/errors.go index 391c0468..3888d6d4 100644 --- a/x/cbdc/types/errors.go +++ b/x/cbdc/types/errors.go @@ -8,11 +8,12 @@ import ( // x/cbdc module sentinel errors var ( - ErrInvalidAmount = sdkerrors.Register(ModuleName, 1, "amount must be positive") - ErrInvalidDenom = sdkerrors.Register(ModuleName, 2, "amount denom must match the configured CBDC denom") - ErrUnauthorized = sdkerrors.Register(ModuleName, 3, "unauthorized signer") - ErrInvalidOwner = sdkerrors.Register(ModuleName, 4, "invalid owner address") - ErrBlockedAddr = sdkerrors.Register(ModuleName, 5, "address is blocked from receiving funds") - ErrSendDisabled = sdkerrors.Register(ModuleName, 6, "transfers are disabled for the CBDC denom") - ErrIssuancePaused = sdkerrors.Register(ModuleName, 7, "mint/burn is paused") + ErrInvalidAmount = sdkerrors.Register(ModuleName, 1, "amount must be positive") + ErrInvalidDenom = sdkerrors.Register(ModuleName, 2, "amount denom must match the configured CBDC denom") + ErrUnauthorized = sdkerrors.Register(ModuleName, 3, "unauthorized signer") + ErrInvalidOwner = sdkerrors.Register(ModuleName, 4, "invalid owner address") + ErrBlockedAddr = sdkerrors.Register(ModuleName, 5, "address is blocked from receiving funds") + ErrSendDisabled = sdkerrors.Register(ModuleName, 6, "transfers are disabled for the CBDC denom") + ErrIssuancePaused = sdkerrors.Register(ModuleName, 7, "mint/burn is paused") + ErrInvalidMetadata = sdkerrors.Register(ModuleName, 8, "invalid denom metadata") ) diff --git a/x/cbdc/types/expected_keepers.go b/x/cbdc/types/expected_keepers.go index ddfe1fc1..2c6df1a8 100644 --- a/x/cbdc/types/expected_keepers.go +++ b/x/cbdc/types/expected_keepers.go @@ -4,6 +4,7 @@ import ( "context" sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" ) // AccountKeeper defines the expected account keeper used for simulations (noalias) @@ -20,4 +21,5 @@ type BankKeeper interface { SendCoinsFromAccountToModule(ctx context.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error BlockedAddr(addr sdk.AccAddress) bool IsSendEnabledCoin(ctx context.Context, coin sdk.Coin) bool + SetDenomMetaData(ctx context.Context, denomMetaData banktypes.Metadata) } diff --git a/x/cbdc/types/message_set_denom_metadata.go b/x/cbdc/types/message_set_denom_metadata.go new file mode 100644 index 00000000..4e1d7403 --- /dev/null +++ b/x/cbdc/types/message_set_denom_metadata.go @@ -0,0 +1,56 @@ +package types + +import ( + "strings" + + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" +) + +var _ sdk.Msg = &MsgSetDenomMetadata{} + +func NewMsgSetDenomMetadata(authority string, metadata banktypes.Metadata) *MsgSetDenomMetadata { + return &MsgSetDenomMetadata{ + Authority: authority, + Metadata: metadata, + } +} + +// ValidateBasic performs stateless validation of the message. +func (msg *MsgSetDenomMetadata) ValidateBasic() error { + if _, err := sdk.AccAddressFromBech32(msg.Authority); err != nil { + return errorsmod.Wrapf(err, "invalid authority address (%s)", msg.Authority) + } + if err := msg.Metadata.Validate(); err != nil { + return errorsmod.Wrap(ErrInvalidMetadata, err.Error()) + } + return ValidateMetadataDisplayResolvable(msg.Metadata) +} + +// ValidateMetadataDisplayResolvable rejects metadata whose decimals the erc20 +// precompile cannot resolve. The precompile matches Display against the denom +// units to find the exponent, but for an ibc/ base it matches only the LAST +// '/'-separated segment of Display (the trace prefix is stripped). x/bank's +// Metadata.Validate only requires a unit named after the FULL Display, so a +// trace-qualified Display like transfer/client-0/uusd with no "uusd" unit +// passes SDK validation and still makes every decimals() call revert with +// "display denomination not found" — which is exactly the kind of mislabelled +// voucher this message exists to fix, so it must not be able to write one. +func ValidateMetadataDisplayResolvable(metadata banktypes.Metadata) error { + display := metadata.Display + if strings.HasPrefix(metadata.Base, "ibc/") { + segments := strings.Split(metadata.Display, "/") + display = segments[len(segments)-1] + } + for _, unit := range metadata.DenomUnits { + if unit.Denom == display { + return nil + } + } + return errorsmod.Wrapf( + ErrInvalidMetadata, + "no denom unit named %q: for an ibc/ base the erc20 precompile resolves decimals by matching the last '/'-segment of display (%q) against a denom unit, so this metadata would make decimals() revert with \"display denomination not found\"", + display, metadata.Display, + ) +} diff --git a/x/cbdc/types/tx.pb.go b/x/cbdc/types/tx.pb.go index e1bd5b83..0be72557 100644 --- a/x/cbdc/types/tx.pb.go +++ b/x/cbdc/types/tx.pb.go @@ -10,6 +10,7 @@ import ( types "github.com/cosmos/cosmos-sdk/types" _ "github.com/cosmos/cosmos-sdk/types/msgservice" _ "github.com/cosmos/cosmos-sdk/types/tx/amino" + types1 "github.com/cosmos/cosmos-sdk/x/bank/types" _ "github.com/cosmos/gogoproto/gogoproto" grpc1 "github.com/cosmos/gogoproto/grpc" proto "github.com/cosmos/gogoproto/proto" @@ -321,6 +322,101 @@ func (m *MsgUpdateParamsResponse) XXX_DiscardUnknown() { var xxx_messageInfo_MsgUpdateParamsResponse proto.InternalMessageInfo +// MsgSetDenomMetadata defines a message to set the bank denom metadata for a +// denom +type MsgSetDenomMetadata struct { + // authority is the address that controls the module params (defaults to the + // gov module account) + Authority string `protobuf:"bytes,1,opt,name=authority,proto3" json:"authority,omitempty"` + // metadata is the full denom metadata to store, replacing whatever the bank + // module currently holds for its base denom + Metadata types1.Metadata `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata"` +} + +func (m *MsgSetDenomMetadata) Reset() { *m = MsgSetDenomMetadata{} } +func (m *MsgSetDenomMetadata) String() string { return proto.CompactTextString(m) } +func (*MsgSetDenomMetadata) ProtoMessage() {} +func (*MsgSetDenomMetadata) Descriptor() ([]byte, []int) { + return fileDescriptor_870101f11584fe74, []int{6} +} +func (m *MsgSetDenomMetadata) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *MsgSetDenomMetadata) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_MsgSetDenomMetadata.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *MsgSetDenomMetadata) XXX_Merge(src proto.Message) { + xxx_messageInfo_MsgSetDenomMetadata.Merge(m, src) +} +func (m *MsgSetDenomMetadata) XXX_Size() int { + return m.Size() +} +func (m *MsgSetDenomMetadata) XXX_DiscardUnknown() { + xxx_messageInfo_MsgSetDenomMetadata.DiscardUnknown(m) +} + +var xxx_messageInfo_MsgSetDenomMetadata proto.InternalMessageInfo + +func (m *MsgSetDenomMetadata) GetAuthority() string { + if m != nil { + return m.Authority + } + return "" +} + +func (m *MsgSetDenomMetadata) GetMetadata() types1.Metadata { + if m != nil { + return m.Metadata + } + return types1.Metadata{} +} + +// MsgSetDenomMetadataResponse defines the response for setting denom metadata +type MsgSetDenomMetadataResponse struct { +} + +func (m *MsgSetDenomMetadataResponse) Reset() { *m = MsgSetDenomMetadataResponse{} } +func (m *MsgSetDenomMetadataResponse) String() string { return proto.CompactTextString(m) } +func (*MsgSetDenomMetadataResponse) ProtoMessage() {} +func (*MsgSetDenomMetadataResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_870101f11584fe74, []int{7} +} +func (m *MsgSetDenomMetadataResponse) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *MsgSetDenomMetadataResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_MsgSetDenomMetadataResponse.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *MsgSetDenomMetadataResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_MsgSetDenomMetadataResponse.Merge(m, src) +} +func (m *MsgSetDenomMetadataResponse) XXX_Size() int { + return m.Size() +} +func (m *MsgSetDenomMetadataResponse) XXX_DiscardUnknown() { + xxx_messageInfo_MsgSetDenomMetadataResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_MsgSetDenomMetadataResponse proto.InternalMessageInfo + func init() { proto.RegisterType((*MsgMint)(nil), "cbdc.MsgMint") proto.RegisterType((*MsgMintResponse)(nil), "cbdc.MsgMintResponse") @@ -328,42 +424,48 @@ func init() { proto.RegisterType((*MsgBurnResponse)(nil), "cbdc.MsgBurnResponse") proto.RegisterType((*MsgUpdateParams)(nil), "cbdc.MsgUpdateParams") proto.RegisterType((*MsgUpdateParamsResponse)(nil), "cbdc.MsgUpdateParamsResponse") + proto.RegisterType((*MsgSetDenomMetadata)(nil), "cbdc.MsgSetDenomMetadata") + proto.RegisterType((*MsgSetDenomMetadataResponse)(nil), "cbdc.MsgSetDenomMetadataResponse") } func init() { proto.RegisterFile("cbdc/tx.proto", fileDescriptor_870101f11584fe74) } var fileDescriptor_870101f11584fe74 = []byte{ - // 465 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xd4, 0x53, 0x3f, 0x6f, 0xd4, 0x30, - 0x1c, 0x3d, 0xd3, 0xf6, 0xaa, 0x73, 0x5b, 0x50, 0xad, 0xa2, 0xde, 0x45, 0x22, 0x54, 0x37, 0x55, - 0x27, 0x6a, 0xeb, 0x82, 0xc4, 0x50, 0xb1, 0x10, 0xba, 0x9e, 0x84, 0x0e, 0xb1, 0xb0, 0x20, 0x27, - 0xb1, 0xd2, 0x0c, 0xb1, 0x23, 0xdb, 0x29, 0xbd, 0x0d, 0x31, 0x32, 0xf1, 0x31, 0x98, 0x50, 0x07, - 0x24, 0xbe, 0x42, 0xc7, 0x8a, 0x89, 0x09, 0xa1, 0xbb, 0xa1, 0x5f, 0x03, 0xf9, 0x4f, 0xc8, 0x1d, - 0x42, 0xba, 0xb9, 0x4b, 0x92, 0xdf, 0xef, 0x3d, 0xbf, 0xbc, 0xe7, 0x9f, 0x0d, 0xf7, 0xd2, 0x24, - 0x4b, 0x89, 0xbe, 0xc4, 0x95, 0x14, 0x5a, 0xa0, 0x4d, 0x53, 0x06, 0x07, 0xb9, 0xc8, 0x85, 0x6d, - 0x10, 0xf3, 0xe5, 0xb0, 0x60, 0x90, 0x0a, 0x55, 0x0a, 0xf5, 0xce, 0x01, 0xae, 0xf0, 0xd0, 0xa1, - 0xab, 0x48, 0xa9, 0x72, 0x72, 0x31, 0x36, 0x2f, 0x0f, 0x84, 0x1e, 0x48, 0xa8, 0x62, 0xe4, 0x62, - 0x9c, 0x30, 0x4d, 0xc7, 0x24, 0x15, 0x05, 0xf7, 0xf8, 0x3e, 0x2d, 0x0b, 0x2e, 0x88, 0x7d, 0x36, - 0x2d, 0xeb, 0xa8, 0xa2, 0x92, 0x96, 0x5e, 0x7e, 0xf8, 0x1d, 0xc0, 0xed, 0x89, 0xca, 0x27, 0x05, - 0xd7, 0x08, 0xc3, 0x2d, 0xf1, 0x9e, 0x33, 0xd9, 0x07, 0x47, 0xe0, 0xb8, 0x17, 0xf7, 0x7f, 0x7c, - 0x3b, 0x39, 0xf0, 0x5e, 0x5e, 0x64, 0x99, 0x64, 0x4a, 0xbd, 0xd6, 0xb2, 0xe0, 0xf9, 0xd4, 0xd1, - 0x50, 0x04, 0xb7, 0xa9, 0xeb, 0xf7, 0xef, 0xad, 0x59, 0xd1, 0x10, 0xd1, 0x73, 0xd8, 0xa5, 0xa5, - 0xa8, 0xb9, 0xee, 0x6f, 0x1c, 0x81, 0xe3, 0x9d, 0x68, 0x80, 0x3d, 0xdf, 0xc4, 0xc0, 0x3e, 0x06, - 0x7e, 0x29, 0x0a, 0x1e, 0xf7, 0xae, 0x7f, 0x3d, 0xee, 0x7c, 0xb9, 0xbd, 0x1a, 0x81, 0xa9, 0x5f, - 0x73, 0x0a, 0x3f, 0xde, 0x5e, 0x8d, 0xdc, 0xdf, 0x87, 0xfb, 0xf0, 0x81, 0x37, 0x3e, 0x65, 0xaa, - 0x12, 0x5c, 0xb1, 0x26, 0x4c, 0x5c, 0x4b, 0x7e, 0x27, 0xc3, 0x18, 0xe3, 0x7f, 0xc3, 0x7c, 0x02, - 0xb6, 0xf7, 0xa6, 0xca, 0xa8, 0x66, 0xaf, 0xec, 0xcc, 0xd0, 0x33, 0xd8, 0xa3, 0xb5, 0x3e, 0x17, - 0xb2, 0xd0, 0xb3, 0xb5, 0xc1, 0x5a, 0x2a, 0x22, 0xb0, 0xeb, 0xa6, 0x6e, 0xb3, 0xed, 0x44, 0xbb, - 0xd8, 0x9c, 0x04, 0xec, 0x54, 0x57, 0xbc, 0x39, 0xda, 0xe9, 0x7d, 0xe3, 0xad, 0x15, 0x18, 0x0e, - 0xe0, 0xe1, 0x3f, 0x5e, 0x1a, 0x9f, 0xd1, 0x57, 0x00, 0x37, 0x26, 0x2a, 0x47, 0x4f, 0xe0, 0xa6, - 0x3d, 0x45, 0x7b, 0x4e, 0xdb, 0xcf, 0x26, 0x78, 0xb8, 0x52, 0x36, 0xab, 0x0c, 0xdb, 0x8e, 0xa9, - 0x65, 0x9b, 0x72, 0x89, 0xbd, 0xbc, 0x17, 0xe8, 0x0c, 0xee, 0xae, 0xec, 0x43, 0x4b, 0x5b, 0x6e, - 0x07, 0x8f, 0xfe, 0xdb, 0x6e, 0x54, 0x82, 0xad, 0x0f, 0x26, 0x63, 0x7c, 0x76, 0x3d, 0x0f, 0xc1, - 0xcd, 0x3c, 0x04, 0xbf, 0xe7, 0x21, 0xf8, 0xbc, 0x08, 0x3b, 0x37, 0x8b, 0xb0, 0xf3, 0x73, 0x11, - 0x76, 0xde, 0x8e, 0xf2, 0x42, 0x9f, 0xd7, 0x09, 0x4e, 0x45, 0x49, 0x2a, 0xc6, 0xa4, 0x9a, 0x29, - 0x4d, 0x8c, 0xe4, 0x09, 0x17, 0x19, 0x23, 0x97, 0xc4, 0xdd, 0xe8, 0x59, 0xc5, 0x54, 0xd2, 0xb5, - 0xf7, 0xe7, 0xe9, 0x9f, 0x00, 0x00, 0x00, 0xff, 0xff, 0x81, 0x9a, 0x1b, 0xe6, 0xe6, 0x03, 0x00, - 0x00, + // 541 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xd4, 0x54, 0x3d, 0x6f, 0x13, 0x41, + 0x10, 0xf5, 0xe6, 0xc3, 0xc1, 0x9b, 0x04, 0xc8, 0x11, 0x14, 0xfb, 0x90, 0x8f, 0xe0, 0x2a, 0xb2, + 0xc8, 0xad, 0x6c, 0x24, 0x8a, 0x88, 0x06, 0xe3, 0xf6, 0xa4, 0xc8, 0x11, 0x0d, 0x0d, 0xda, 0xbb, + 0x5b, 0x5d, 0x4e, 0xd1, 0xed, 0x9e, 0x6e, 0xd7, 0x21, 0xee, 0x22, 0x4a, 0x2a, 0xfe, 0x03, 0x0d, + 0x65, 0x0a, 0x24, 0xfe, 0x42, 0xca, 0x88, 0x8a, 0x0a, 0x21, 0xbb, 0xc8, 0xdf, 0x40, 0xfb, 0x71, + 0x1f, 0x8e, 0x83, 0x22, 0xd1, 0xd1, 0xd8, 0x9e, 0x79, 0x6f, 0x66, 0xdf, 0x9b, 0x59, 0x2f, 0xdc, + 0x0c, 0xfc, 0x30, 0x40, 0xe2, 0xcc, 0x4d, 0x33, 0x26, 0x98, 0xb5, 0x22, 0x43, 0x7b, 0x3b, 0x62, + 0x11, 0x53, 0x09, 0x24, 0x7f, 0x69, 0xcc, 0x6e, 0x05, 0x8c, 0x27, 0x8c, 0xbf, 0xd7, 0x80, 0x0e, + 0x0c, 0xb4, 0xa3, 0x23, 0x94, 0xf0, 0x08, 0x9d, 0xf6, 0xe4, 0x97, 0x01, 0x1c, 0x03, 0xf8, 0x98, + 0x13, 0x74, 0xda, 0xf3, 0x89, 0xc0, 0x3d, 0x14, 0xb0, 0x98, 0x2e, 0xe0, 0xf4, 0xa4, 0xc0, 0x65, + 0x60, 0xf0, 0x2d, 0x9c, 0xc4, 0x94, 0x21, 0xf5, 0x99, 0xa7, 0x94, 0xe2, 0x14, 0x67, 0x38, 0x31, + 0xc7, 0x77, 0xbe, 0x03, 0xb8, 0xe6, 0xf1, 0xc8, 0x8b, 0xa9, 0xb0, 0x5c, 0xb8, 0xca, 0x3e, 0x50, + 0x92, 0x35, 0xc1, 0x2e, 0xd8, 0x6b, 0x0c, 0x9a, 0x3f, 0xbe, 0xed, 0x6f, 0x1b, 0xad, 0xaf, 0xc3, + 0x30, 0x23, 0x9c, 0x1f, 0x89, 0x2c, 0xa6, 0xd1, 0x48, 0xd3, 0xac, 0x3e, 0x5c, 0xc3, 0x3a, 0xdf, + 0x5c, 0xba, 0xa3, 0x22, 0x27, 0x5a, 0xaf, 0x60, 0x1d, 0x27, 0x6c, 0x4c, 0x45, 0x73, 0x79, 0x17, + 0xec, 0xad, 0xf7, 0x5b, 0xae, 0xe1, 0x4b, 0x9b, 0xae, 0xb1, 0xe1, 0xbe, 0x61, 0x31, 0x1d, 0x34, + 0x2e, 0x7f, 0x3d, 0xad, 0x7d, 0xbd, 0xbe, 0xe8, 0x82, 0x91, 0xa9, 0x39, 0x80, 0x1f, 0xaf, 0x2f, + 0xba, 0xfa, 0xf4, 0xce, 0x16, 0x7c, 0x60, 0x84, 0x8f, 0x08, 0x4f, 0x19, 0xe5, 0x24, 0x37, 0x33, + 0x18, 0x67, 0xf4, 0xbf, 0x34, 0x23, 0x85, 0x17, 0x66, 0x3e, 0x01, 0x95, 0x7b, 0x9b, 0x86, 0x58, + 0x90, 0x43, 0xb5, 0x33, 0xeb, 0x25, 0x6c, 0xe0, 0xb1, 0x38, 0x66, 0x59, 0x2c, 0x26, 0x77, 0x1a, + 0x2b, 0xa9, 0x16, 0x82, 0x75, 0xbd, 0x75, 0xe5, 0x6d, 0xbd, 0xbf, 0xe1, 0xca, 0x9b, 0xe0, 0xea, + 0xae, 0x73, 0xda, 0x34, 0xed, 0xe0, 0xbe, 0xd4, 0x56, 0x36, 0xe8, 0xb4, 0xe0, 0xce, 0x0d, 0x2d, + 0x85, 0xce, 0x2f, 0x00, 0x3e, 0xf2, 0x78, 0x74, 0x44, 0xc4, 0x90, 0x50, 0x96, 0x78, 0x44, 0xe0, + 0x10, 0x0b, 0xfc, 0xcf, 0x5a, 0x87, 0xf0, 0x5e, 0x62, 0x7a, 0x18, 0xb5, 0xed, 0x72, 0xac, 0xf4, + 0xa4, 0x18, 0x6b, 0x7e, 0x50, 0x55, 0x7e, 0x51, 0xb9, 0x60, 0xa0, 0x0d, 0x9f, 0xdc, 0x22, 0x32, + 0x37, 0xd1, 0x3f, 0x5f, 0x82, 0xcb, 0x1e, 0x8f, 0xac, 0xe7, 0x70, 0x45, 0xfd, 0x15, 0x36, 0xf5, + 0x80, 0xcc, 0x05, 0xb3, 0x1f, 0xcf, 0x85, 0x79, 0x95, 0x64, 0xab, 0xbb, 0x56, 0xb2, 0x65, 0x58, + 0x61, 0x57, 0x17, 0x6a, 0x0d, 0xe1, 0xc6, 0xdc, 0x32, 0x4b, 0x5a, 0x35, 0x6d, 0xb7, 0x6f, 0x4d, + 0x17, 0x5d, 0x0e, 0xe1, 0xc3, 0x85, 0x51, 0xb7, 0x8a, 0x92, 0x9b, 0x90, 0xfd, 0xec, 0xaf, 0x50, + 0xde, 0xd1, 0x5e, 0x3d, 0x97, 0xb3, 0x1b, 0x0c, 0x2f, 0xa7, 0x0e, 0xb8, 0x9a, 0x3a, 0xe0, 0xf7, + 0xd4, 0x01, 0x9f, 0x67, 0x4e, 0xed, 0x6a, 0xe6, 0xd4, 0x7e, 0xce, 0x9c, 0xda, 0xbb, 0x6e, 0x14, + 0x8b, 0xe3, 0xb1, 0xef, 0x06, 0x2c, 0x41, 0x29, 0x21, 0x19, 0x9f, 0x70, 0x81, 0x64, 0xdb, 0x7d, + 0xca, 0x42, 0x82, 0xce, 0x90, 0x7e, 0x08, 0x27, 0x29, 0xe1, 0x7e, 0x5d, 0x3d, 0x2b, 0x2f, 0xfe, + 0x04, 0x00, 0x00, 0xff, 0xff, 0xc2, 0x97, 0x22, 0x49, 0x1d, 0x05, 0x00, 0x00, } // Reference imports to suppress errors if they are not otherwise used. @@ -384,6 +486,9 @@ type MsgClient interface { Burn(ctx context.Context, in *MsgBurn, opts ...grpc.CallOption) (*MsgBurnResponse, error) // Updates the module params. Only the governance authority can call it. UpdateParams(ctx context.Context, in *MsgUpdateParams, opts ...grpc.CallOption) (*MsgUpdateParamsResponse, error) + // Sets the bank denom metadata for a denom, overwriting any existing entry. + // Only the governance authority can call it. + SetDenomMetadata(ctx context.Context, in *MsgSetDenomMetadata, opts ...grpc.CallOption) (*MsgSetDenomMetadataResponse, error) } type msgClient struct { @@ -421,6 +526,15 @@ func (c *msgClient) UpdateParams(ctx context.Context, in *MsgUpdateParams, opts return out, nil } +func (c *msgClient) SetDenomMetadata(ctx context.Context, in *MsgSetDenomMetadata, opts ...grpc.CallOption) (*MsgSetDenomMetadataResponse, error) { + out := new(MsgSetDenomMetadataResponse) + err := c.cc.Invoke(ctx, "/cbdc.Msg/SetDenomMetadata", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // MsgServer is the server API for Msg service. type MsgServer interface { // Mints CBDC tokens and sends them to an address @@ -429,6 +543,9 @@ type MsgServer interface { Burn(context.Context, *MsgBurn) (*MsgBurnResponse, error) // Updates the module params. Only the governance authority can call it. UpdateParams(context.Context, *MsgUpdateParams) (*MsgUpdateParamsResponse, error) + // Sets the bank denom metadata for a denom, overwriting any existing entry. + // Only the governance authority can call it. + SetDenomMetadata(context.Context, *MsgSetDenomMetadata) (*MsgSetDenomMetadataResponse, error) } // UnimplementedMsgServer can be embedded to have forward compatible implementations. @@ -444,6 +561,9 @@ func (*UnimplementedMsgServer) Burn(ctx context.Context, req *MsgBurn) (*MsgBurn func (*UnimplementedMsgServer) UpdateParams(ctx context.Context, req *MsgUpdateParams) (*MsgUpdateParamsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method UpdateParams not implemented") } +func (*UnimplementedMsgServer) SetDenomMetadata(ctx context.Context, req *MsgSetDenomMetadata) (*MsgSetDenomMetadataResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetDenomMetadata not implemented") +} func RegisterMsgServer(s grpc1.Server, srv MsgServer) { s.RegisterService(&_Msg_serviceDesc, srv) @@ -503,6 +623,24 @@ func _Msg_UpdateParams_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } +func _Msg_SetDenomMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MsgSetDenomMetadata) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MsgServer).SetDenomMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/cbdc.Msg/SetDenomMetadata", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MsgServer).SetDenomMetadata(ctx, req.(*MsgSetDenomMetadata)) + } + return interceptor(ctx, in, info, handler) +} + var _Msg_serviceDesc = grpc.ServiceDesc{ ServiceName: "cbdc.Msg", HandlerType: (*MsgServer)(nil), @@ -519,6 +657,10 @@ var _Msg_serviceDesc = grpc.ServiceDesc{ MethodName: "UpdateParams", Handler: _Msg_UpdateParams_Handler, }, + { + MethodName: "SetDenomMetadata", + Handler: _Msg_SetDenomMetadata_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "cbdc/tx.proto", @@ -727,6 +869,69 @@ func (m *MsgUpdateParamsResponse) MarshalToSizedBuffer(dAtA []byte) (int, error) return len(dAtA) - i, nil } +func (m *MsgSetDenomMetadata) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *MsgSetDenomMetadata) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *MsgSetDenomMetadata) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + { + size, err := m.Metadata.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintTx(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + if len(m.Authority) > 0 { + i -= len(m.Authority) + copy(dAtA[i:], m.Authority) + i = encodeVarintTx(dAtA, i, uint64(len(m.Authority))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *MsgSetDenomMetadataResponse) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *MsgSetDenomMetadataResponse) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *MsgSetDenomMetadataResponse) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + return len(dAtA) - i, nil +} + func encodeVarintTx(dAtA []byte, offset int, v uint64) int { offset -= sovTx(v) base := offset @@ -818,6 +1023,30 @@ func (m *MsgUpdateParamsResponse) Size() (n int) { return n } +func (m *MsgSetDenomMetadata) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Authority) + if l > 0 { + n += 1 + l + sovTx(uint64(l)) + } + l = m.Metadata.Size() + n += 1 + l + sovTx(uint64(l)) + return n +} + +func (m *MsgSetDenomMetadataResponse) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + return n +} + func sovTx(x uint64) (n int) { return (math_bits.Len64(x|1) + 6) / 7 } @@ -1383,6 +1612,171 @@ func (m *MsgUpdateParamsResponse) Unmarshal(dAtA []byte) error { } return nil } +func (m *MsgSetDenomMetadata) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowTx + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: MsgSetDenomMetadata: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: MsgSetDenomMetadata: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Authority", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowTx + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthTx + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthTx + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Authority = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Metadata", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowTx + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthTx + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthTx + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Metadata.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipTx(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthTx + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *MsgSetDenomMetadataResponse) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowTx + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: MsgSetDenomMetadataResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: MsgSetDenomMetadataResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skipTx(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthTx + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skipTx(dAtA []byte) (n int, err error) { l := len(dAtA) iNdEx := 0 From 33829f29e12829b9be8ba7866ed2a643b0435103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 7 Aug 2026 14:56:33 +0200 Subject: [PATCH 39/61] feat(qbftclient): AckMsgs and TimeoutMsgs in relaytx, and route qbftrelay through them The ack and timeout proofs were built inline in cmd/qbftrelay, which meant cmd/qbftproofapi had no shared path to reuse and the two could drift on the one detail that is easy to get wrong: receipt and ack are both keyed by the packet's DESTINATION client, because that is the store they were written into -- the mirror of RecvMsgs' source-client keying. Both now live in relaytx alongside RecvMsgs, and qbftrelay calls them. That also collapses the shadowed-err handling the inline version needed. TimeoutMsgs documents what makes an absence proof safe: it only says "not received AS OF target", and ibc-go additionally requires target's consensus timestamp to be past the packet's timeout, after which the receipt can never legally appear. Co-Authored-By: Claude Opus 5 --- cmd/qbftrelay/main.go | 43 +++++--------- x/qbftclient/prover/relaytx/relaytx.go | 79 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/cmd/qbftrelay/main.go b/cmd/qbftrelay/main.go index f1be88d8..17c5b5ca 100644 --- a/cmd/qbftrelay/main.go +++ b/cmd/qbftrelay/main.go @@ -38,11 +38,8 @@ import ( "github.com/peersyst/cbdc-node/app" "github.com/peersyst/cbdc-node/x/qbftclient" - "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/msgs" "github.com/peersyst/cbdc-node/x/qbftclient/prover/relaytx" - "github.com/peersyst/cbdc-node/x/qbftclient/types" ) func main() { @@ -143,51 +140,39 @@ func run(ctx context.Context, cfg config, packetHex string) error { return err } - p := prover.New(chain, cfg.contract) - - // Three message kinds, three proofs. A receive proves the commitment is PRESENT - // in the counterparty's store; a timeout proves the receipt is ABSENT, which is - // what entitles this chain to refund its own escrow; an ack proves the - // acknowledgement is PRESENT, which clears this chain's commitment for a packet - // the counterparty received. Receipt and ack are both keyed by the packet's - // DESTINATION client, because that is the store they were written into. - var ( - proof *types.StorageProof - final sdk.Msg - ) + // 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: - proof, err = p.PacketReceiptProof(ctx, packet.DestinationClient, packet.Sequence, cfg.target) + touts, err := relaytx.TimeoutMsgs(ctx, chain, cfg.contract, encCfg.Codec, + []channeltypesv2.Packet{packet}, cfg.target, cfg.signer) if err != nil { return err } - final, err = msgs.Timeout(encCfg.Codec, packet, proof, cfg.target, cfg.signer) + final = touts[0] case cfg.ack: ackBz, decErr := hex.DecodeString(cfg.ackHex) if decErr != nil { return fmt.Errorf("decode ack hex: %w", decErr) } - proof, err = p.PacketAckProof(ctx, packet.DestinationClient, packet.Sequence, cfg.target) + acks, err := relaytx.AckMsgs(ctx, chain, cfg.contract, encCfg.Codec, + []channeltypesv2.Packet{packet}, [][]byte{ackBz}, cfg.target, cfg.signer) if err != nil { return err } - // The wire form is the RAW app ack; ibc-go recomputes the commitment - // (sha256(0x02 || sha256(ack))) itself and checks the proof against it, - // so a wrong ack fails verification rather than clearing the commitment. - ack := channeltypesv2.Acknowledgement{AppAcknowledgements: [][]byte{ackBz}} - final, err = msgs.Acknowledgement(encCfg.Codec, packet, ack, proof, cfg.target, cfg.signer) + final = acks[0] default: - recvs, recvErr := relaytx.RecvMsgs(ctx, chain, cfg.contract, encCfg.Codec, + recvs, err := relaytx.RecvMsgs(ctx, chain, cfg.contract, encCfg.Codec, []channeltypesv2.Packet{packet}, cfg.target, cfg.signer) - if recvErr != nil { - return recvErr + if err != nil { + return err } final = recvs[0] } recv := final - if err != nil { - return err - } // The updates must precede the receive in the same transaction: the proof is // verified against the consensus state the last update writes. diff --git a/x/qbftclient/prover/relaytx/relaytx.go b/x/qbftclient/prover/relaytx/relaytx.go index caf60ab9..327e2882 100644 --- a/x/qbftclient/prover/relaytx/relaytx.go +++ b/x/qbftclient/prover/relaytx/relaytx.go @@ -86,3 +86,82 @@ func RecvMsgs( } return out, nil } + +// AckMsgs builds one MsgAcknowledgement per packet, each acknowledgement +// proved PRESENT in the counterparty contract's storage at target — which is +// what clears the send commitment on this chain for a packet the counterparty +// received (and refunds on an error ack). +// +// acks[i] is the RAW app acknowledgement for packets[i], i.e. one element of +// the counterparty WriteAcknowledgement event's acknowledgements array — NOT +// the protobuf Acknowledgement wrapper. ibc-go recomputes the ack commitment +// (sha256(0x02 || sha256(ack))) itself and checks the proof against it, so a +// wrong ack fails verification rather than clearing the commitment. +func AckMsgs( + ctx context.Context, + chain prover.ChainReader, + contract common.Address, + cdc codec.BinaryCodec, + packets []channeltypesv2.Packet, + acks [][]byte, + target uint64, + signer string, +) ([]sdk.Msg, error) { + if len(acks) != len(packets) { + return nil, fmt.Errorf("relaytx: %d packets but %d acks", len(packets), len(acks)) + } + p := prover.New(chain, contract) + out := make([]sdk.Msg, 0, len(packets)) + for i, packet := range packets { + // The ack lives in the DESTINATION client's store — the receiver wrote + // it — the mirror of RecvMsgs' source-client keying. + proof, err := p.PacketAckProof(ctx, packet.DestinationClient, packet.Sequence, target) + if err != nil { + return nil, fmt.Errorf("relaytx: proving ack %d: %w", packet.Sequence, err) + } + ack := channeltypesv2.Acknowledgement{AppAcknowledgements: [][]byte{acks[i]}} + msg, err := msgs.Acknowledgement(cdc, packet, ack, proof, target, signer) + if err != nil { + return nil, err + } + out = append(out, msg) + } + return out, nil +} + +// TimeoutMsgs builds one MsgTimeout per packet, each proving the packet's +// receipt is ABSENT from the counterparty contract's storage at target — which +// is what entitles this chain to refund its own escrow. Like the ack, the +// receipt is keyed by the DESTINATION client, because that is the store it +// would have been written into. +// +// The absence proof only says "not received AS OF target"; what makes the +// refund safe is ibc-go additionally requiring target's consensus timestamp to +// be past the packet's timeout, after which the receipt can never legally +// appear. Callers therefore pair this with UpdateMsgs to a target the +// counterparty has already lived past the timeout at, or the message is +// rejected on delivery. +func TimeoutMsgs( + ctx context.Context, + chain prover.ChainReader, + contract common.Address, + cdc codec.BinaryCodec, + packets []channeltypesv2.Packet, + target uint64, + signer string, +) ([]sdk.Msg, error) { + p := prover.New(chain, contract) + out := make([]sdk.Msg, 0, len(packets)) + for _, packet := range packets { + proof, err := p.PacketReceiptProof(ctx, packet.DestinationClient, packet.Sequence, target) + if err != nil { + return nil, fmt.Errorf("relaytx: proving receipt absence for %d: %w", packet.Sequence, err) + } + msg, err := msgs.Timeout(cdc, packet, proof, target, signer) + if err != nil { + return nil, err + } + out = append(out, msg) + } + return out, nil +} From ab03c81097c08fcadee39e55d4049468376b9b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 7 Aug 2026 14:56:43 +0200 Subject: [PATCH 40/61] feat(qbftproofapi): serve ack and timeout in both proof directions The shim translated recv only. Acks and timeouts travel AGAINST their packet, so each proof direction has to serve packets sent both ways before the relayer can close a round trip on its own. Proofs FROM Besu reuse relaytx's Ack/TimeoutMsgs, so this and cmd/qbftrelay cannot drift on the destination-client keying. Attestations FROM cbdc-node need a second sidecar address: receipt ABSENCE is deliberately not on the AggregatorService surface, because upstream's GetAttestationsRequest cannot distinguish "attest this value" from "attest there is no value". Timeouts therefore go through the sidecar's HTTP /attest/absence, the only surface where that intent is explicit. The sidecar still verifies against its own cbdc-node view before signing, so a caller cannot smuggle in an absence any more than a commitment. Holds no keys of any kind, unchanged (DEC-7). Co-Authored-By: Claude Opus 5 --- cmd/qbftproofapi/evm.go | 192 ++++++++++++++++++++++--- cmd/qbftproofapi/inbound.go | 187 +++++++++++++++++++++---- cmd/qbftproofapi/main.go | 128 ++++++++++++----- cmd/qbftproofapi/outbound.go | 262 ++++++++++++++++++++++++++++++++++- 4 files changed, 688 insertions(+), 81 deletions(-) diff --git a/cmd/qbftproofapi/evm.go b/cmd/qbftproofapi/evm.go index abe7ac46..5a0d2576 100644 --- a/cmd/qbftproofapi/evm.go +++ b/cmd/qbftproofapi/evm.go @@ -59,6 +59,69 @@ const routerABIJSON = `[ ]} ]} ]}, + {"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[]"} ]} @@ -101,6 +164,19 @@ type solMsgRecvPacket struct { 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 { @@ -122,9 +198,29 @@ func toSolPacket(pk channeltypesv2.Packet) solPacket { 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. Inverse of toSolPacket, same mapping as -// cmd/packetconv. +// packet MsgRecvPacket carries. func fromSendPacketLog(data []byte) (channeltypesv2.Packet, error) { out, err := routerABI.Unpack("SendPacket", data) if err != nil { @@ -139,23 +235,34 @@ func fromSendPacketLog(data []byte) (channeltypesv2.Packet, error) { if err != nil { return channeltypesv2.Packet{}, fmt.Errorf("copy SendPacket: %w", err) } + return fromSolPacket(sol), nil +} - packet := channeltypesv2.Packet{ - Sequence: sol.Sequence, - SourceClient: sol.SourceClient, - DestinationClient: sol.DestClient, - TimeoutTimestamp: sol.TimeoutTimestamp, +// 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) } - 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, - }) + 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) } - return packet, nil + 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), @@ -181,3 +288,56 @@ func multicallRecv(dstClient string, stateProof []byte, packets []channeltypesv2 } 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/inbound.go b/cmd/qbftproofapi/inbound.go index 7771f9bf..6d11002b 100644 --- a/cmd/qbftproofapi/inbound.go +++ b/cmd/qbftproofapi/inbound.go @@ -1,7 +1,12 @@ package main -// Inbound: Besu -> cbdc-node. Real MPT proofs out of the router's storage, -// assembled into the unsigned TxBody the relayer signs and broadcasts. +// 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" @@ -18,6 +23,7 @@ import ( 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" @@ -25,8 +31,6 @@ import ( "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 { @@ -53,36 +57,132 @@ func (s *server) inbound(ctx context.Context, req *proofapipb.RelayByTxRequest) return nil, fmt.Errorf("no SendPacket events for client %s in the given transactions", srcClient) } - trusted, err := s.clientLatestHeight(ctx, dstClient) + msgs, target, err := s.updatesToHead(ctx, chain, eth, dstClient) if err != nil { return nil, err } - head, err := eth.BlockNumber(ctx) + recvs, err := relaytx.RecvMsgs(ctx, chain, s.cfg.router, s.cdc, packets, target, s.cfg.signer) if err != nil { - return nil, fmt.Errorf("besu head: %w", err) + return nil, err } + return txBodyResponse(append(msgs, recvs...)) +} - // 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. - target := head - var msgs []sdk.Msg - if target > trusted { - updates, err := relaytx.UpdateMsgs(ctx, chain, s.cfg.router, dstClient, trusted, target, s.cfg.signer) - if err != nil { - return nil, err - } - msgs = updates - } else { - target = trusted +// 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) - recvs, err := relaytx.RecvMsgs(ctx, chain, s.cfg.router, s.cdc, packets, target, s.cfg.signer) + 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 } - msgs = append(msgs, recvs...) + 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) @@ -91,14 +191,10 @@ func (s *server) inbound(ctx context.Context, req *proofapipb.RelayByTxRequest) } anys = append(anys, a) } - // 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 above carries the relayer's address as signer. 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 @@ -134,6 +230,45 @@ func (s *server) packetsFromReceipts(ctx context.Context, eth *ethclient.Client, 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) { diff --git a/cmd/qbftproofapi/main.go b/cmd/qbftproofapi/main.go index 7bb326d7..50c57f9a 100644 --- a/cmd/qbftproofapi/main.go +++ b/cmd/qbftproofapi/main.go @@ -4,19 +4,26 @@ // // The relayer calls exactly one RPC — proofapi.ProofApiService/RelayByTx — and // signs/broadcasts whatever comes back itself. So this process is a pure -// translator: +// 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): // -// - Besu -> cbdc-node: build the UNSIGNED cosmos TxBody carrying -// [MsgUpdateClient..., MsgRecvPacket...], 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. -// - cbdc-node -> Besu: build ICS26Router multicall calldata -// [updateClient, recvPacket...], attestations fetched from the attestor -// sidecar's AggregatorService. The sidecar verifies against its own -// cbdc-node view before signing, so nothing a caller sends here can smuggle -// in a commitment. +// - 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. @@ -55,6 +62,7 @@ type config struct { besuRPC string cbdcRPC string attestorGRPC string + attestorHTTP 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) @@ -71,6 +79,11 @@ func main() { 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 AggregatorService") + // Two addresses for one sidecar because absence is deliberately not on its + // gRPC surface: upstream's GetAttestationsRequest cannot distinguish + // "attest this value" from "attest there is no value", so timeouts go + // through the HTTP endpoint where that intent is explicit. + flag.StringVar(&cfg.attestorHTTP, "attestor-http", "http://127.0.0.1:8090", "attestor sidecar HTTP (for /attest/absence)") 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)") @@ -121,8 +134,8 @@ func main() { // 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, proofs via x/qbftclient/prover, msgs signed by %s", cfg.besuChainID, cfg.cbdcChainID, cfg.signer) - log.Printf(" %s -> %s : ICS26Router multicall via attestor at %s", cfg.cbdcChainID, cfg.besuChainID, cfg.attestorGRPC) + 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 (absence via %s)", cfg.cbdcChainID, cfg.besuChainID, cfg.attestorGRPC, cfg.attestorHTTP) if err := grpcSrv.Serve(lis); err != nil { log.Fatalf("serve: %v", err) } @@ -136,45 +149,86 @@ type server struct { } // RelayByTx is the one method cosmos/ibc-relayer invokes. Dispatch is on the -// exact chain_id strings the relayer was configured with. +// 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 and, for acks and timeouts, the - // same (src,dst) pair as the opposite recv direction. They are told apart by - // which fields are set (see upstream batch_ack_packet.go / timeout_packet.go): - // timeouts fill timeout_tx_ids, acks fill dst_packet_sequences only. This - // shim is recv-only — the relayer config disables acks, and timeouts are - // avoided with generous packet timeouts — so anything else is refused - // loudly rather than mis-relayed as a receive. - if len(req.GetTimeoutTxIds()) > 0 { - return nil, status.Error(codes.Unimplemented, "timeout relaying is not supported by this shim") - } - if len(req.GetDstPacketSequences()) > 0 && len(req.GetSrcPacketSequences()) == 0 { - return nil, status.Error(codes.Unimplemented, "ack relaying is not supported by this shim") - } - if len(req.GetSourceTxIds()) == 0 { + // 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: - resp, err := s.inbound(ctx, req) - logOutcome("recv "+s.cfg.besuChainID+"->"+s.cfg.cbdcChainID, req, err) - return resp, err + // 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: - resp, err := s.outbound(ctx, req) - logOutcome("recv "+s.cfg.cbdcChainID+"->"+s.cfg.besuChainID, req, err) - return resp, err + // 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, req.GetSrcPacketSequences(), err) + log.Printf("%s seqs=%v: %v", dir, seqs, err) return } - log.Printf("%s seqs=%v: ok (%d txs)", dir, req.GetSrcPacketSequences(), len(req.GetSourceTxIds())) + log.Printf("%s seqs=%v: ok (%d txs)", dir, seqs, txs) } // CreateClient, UpdateClient and Info exist in upstream's proto but are never diff --git a/cmd/qbftproofapi/outbound.go b/cmd/qbftproofapi/outbound.go index ef58e36c..17bdb6ec 100644 --- a/cmd/qbftproofapi/outbound.go +++ b/cmd/qbftproofapi/outbound.go @@ -1,13 +1,25 @@ package main -// Outbound: cbdc-node -> Besu. No proof to build — the attestor sidecar -// attests (state + packet membership) and the AttestationLightClient checks +// 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" + "encoding/json" "fmt" + "io" + "net/http" + "strings" + "time" + + "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" @@ -80,6 +92,188 @@ func (s *server) outbound(ctx context.Context, req *proofapipb.RelayByTxRequest) 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 := uint64(st.SyncInfo.LatestBlockHeight) + + paths := make([][]byte, 0, len(packets)) + for _, pk := range packets { + // The ack is keyed by the packet's DESTINATION client — the receiver + // wrote it — the mirror of the commitment keying in outbound recv. + paths = append(paths, attestor.AckPath(pk.DestinationClient, pk.Sequence)) + } + att, err := s.attestor.GetAttestations(ctx, &aggregatorpb.GetAttestationsRequest{ + Height: height, + Packets: paths, + }) + if err != nil { + return nil, fmt.Errorf("attestor: %w", err) + } + if att.GetStateAttestation() == nil || att.GetPacketAttestation() == nil { + return nil, fmt.Errorf("attestor returned incomplete attestations for height %d", height) + } + + stateProof, err := attestor.EncodeProof(att.GetStateAttestation().GetAttestedData(), att.GetStateAttestation().GetSignatures()) + if err != nil { + return nil, fmt.Errorf("encode state proof: %w", err) + } + packetProof, err := attestor.EncodeProof(att.GetPacketAttestation().GetAttestedData(), att.GetPacketAttestation().GetSignatures()) + if err != nil { + return nil, fmt.Errorf("encode packet proof: %w", 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) + } + // The sidecar signs receipt paths keyed by ITS configured cbdc-node client + // id; a packet destined elsewhere would get 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. + seqs := make([]uint64, 0, len(packets)) + 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) + } + seqs = append(seqs, pk.Sequence) + } + + st, err := s.cbdc.Status(ctx) + if err != nil { + return nil, fmt.Errorf("cbdc status: %w", err) + } + height := uint64(st.SyncInfo.LatestBlockHeight) + + // The state attestation comes from the same gRPC surface as ever — with no + // packets requested, so only the updateClient half is signed. The absence + // half CANNOT come from there: upstream's request has no way to say + // "attest there is no value", so the sidecar keeps non-membership behind + // its explicit-intent HTTP endpoint (see cmd/qbftattestor/grpc.go). + att, err := s.attestor.GetAttestations(ctx, &aggregatorpb.GetAttestationsRequest{Height: height}) + if err != nil { + return nil, fmt.Errorf("attestor: %w", err) + } + if att.GetStateAttestation() == nil { + return nil, fmt.Errorf("attestor returned no state attestation for height %d", height) + } + stateProof, err := attestor.EncodeProof(att.GetStateAttestation().GetAttestedData(), att.GetStateAttestation().GetSignatures()) + if err != nil { + return nil, fmt.Errorf("encode state proof: %w", err) + } + + absenceProof, err := s.attestAbsence(ctx, height, seqs) + 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 +} + +// attestAbsence asks the sidecar's HTTP surface to sign that no receipt exists +// for the given sequences at height. The response's proof is already the full +// abi.encode(AttestationProof) blob — the sidecar wraps absence proofs itself, +// unlike the gRPC path which returns the halves for us to encode. +func (s *server) attestAbsence(ctx context.Context, height uint64, seqs []uint64) ([]byte, error) { + body, err := json.Marshal(map[string]any{"height": height, "sequences": seqs}) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.attestorHTTP+"/attest/absence", bytes.NewReader(body)) + if err != nil { + return nil, err + } + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(httpReq) + if err != nil { + return nil, fmt.Errorf("attestor absence: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // The refusal body says WHY the attestor declined — a value exists, the + // height is unproven, etc. — which is the difference between a + // diagnosable corridor and a silent retry loop. Capped so a misbehaving + // endpoint cannot stream unboundedly. + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + return nil, fmt.Errorf("attestor refused absence (%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, fmt.Errorf("attestor absence response: %w", err) + } + proof, err := hex.DecodeString(strings.TrimPrefix(out.Proof, "0x")) + if err != nil { + return nil, fmt.Errorf("attestor absence proof hex: %w", err) + } + return proof, nil +} + // 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) { @@ -120,3 +314,67 @@ func (s *server) packetsFromCosmosTxs(ctx context.Context, txIDs [][]byte, srcCl } 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 +} From c397d94c32a9d72a12ec2ae42ee9c241e4c37338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 7 Aug 2026 14:57:00 +0200 Subject: [PATCH 41/61] fix(app): BaseDenom is ahnl, the denom the issuer stack actually issues BaseDenom is not just the EVM default; app.go passes it to the x/cbdc keeper, which rejects every other denom with ErrInvalidDenom. With acbdc here, every mint failed inside x/group -- which reports the proposal as ACCEPTED while the inner message failed, so issuance silently did nothing. Recorded in the comment because the failure mode gives no signal at the proposal layer, and because the denom is NOT persisted (x/cbdc Params carry only owner, issuance_paused and paused_ibc_clients), so changing it is a binary change rather than a migration. Co-Authored-By: Claude Opus 5 --- app/config.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/config.go b/app/config.go index 76e4de5b..934560d1 100644 --- a/app/config.go +++ b/app/config.go @@ -6,8 +6,16 @@ const ( AccountAddressPrefix = "ethm" Bip44CoinType = 60 Name = "cbdc" - // BaseDenom defines to the default denomination used in EVM - BaseDenom = "acbdc" + // BaseDenom defines to the default denomination used in EVM, and is also the + // ONLY denom x/cbdc will mint or burn (app.go passes it to the keeper, which + // rejects everything else with ErrInvalidDenom). It is deliberately the + // Honduran Lempira: the issuer stack issues `ahnl`, so anything else here + // makes every mint fail inside x/group -- which reports the proposal as + // ACCEPTED while the inner message failed, so the issuance silently does + // nothing. The denom is NOT persisted (x/cbdc Params carry only owner, + // issuance_paused and paused_ibc_clients), so changing it is a binary + // change, not a migration. + BaseDenom = "ahnl" Denom = "CBDC" DenomDescription = "CBDC is the digital central bank currency." DenomName = "CBDC" From 4f887481455b3814a9095139f04288cbcb2c06fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 7 Aug 2026 14:57:00 +0200 Subject: [PATCH 42/61] fix(scripts): seed metadata under the destination's client id, and split display from symbol Two mistakes this script made easy, both uncorrectable after the first packet because ibc-go only synthesises metadata when none exists. must be the client on THIS chain tracking the source, not the one the sender passes to `tx ibc-transfer transfer`. ICS-20 prefixes the denom with the destination-side client. Verified on a live v2 transfer: a send over 07-tendermint-1 landed as transfer/07-tendermint-0/. display is now a separate optional argument defaulting to symbol. x/bank requires display to name one of the denom_units, but a '/' -- legal in a denom, awkward as an ERC20 symbol() -- is exactly why the two need to be separable. Co-Authored-By: Claude Opus 5 --- scripts/seed-voucher-metadata.sh | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/scripts/seed-voucher-metadata.sh b/scripts/seed-voucher-metadata.sh index b4de4049..bbfab9d5 100755 --- a/scripts/seed-voucher-metadata.sh +++ b/scripts/seed-voucher-metadata.sh @@ -27,8 +27,24 @@ # The voucher denom is deterministic: sha256 of the trace, so it can be computed # before any packet moves, given the client id and the spoke's token address. # +# WHICH CLIENT ID -- THE DESTINATION'S, NOT THE SENDER'S +# +# must be the client on THIS chain that tracks the source chain, not +# the client the sender passes to `tx ibc-transfer transfer`. ICS-20 prefixes the +# denom with the destination-side client, so seeding the sender's id produces +# metadata for a denom that never arrives -- and because ibc-go only synthesises +# metadata when none exists, that mistake cannot be corrected after the first +# packet. Verified on a live v2 transfer: a send over client 07-tendermint-1 +# (source -> destination) landed as transfer/07-tendermint-0/, where +# 07-tendermint-0 is the destination's client back to the source. +# # Usage: -# seed-voucher-metadata.sh <0xtoken> +# seed-voucher-metadata.sh <0xtoken> [display] +# +# is optional and defaults to . Pass it when the display unit +# should differ from the ticker -- e.g. symbol BRL with display brasil/BRL. A '/' +# is legal in a denom (SDK allows [a-zA-Z][a-zA-Z0-9/:._-]{2,127}) but makes an +# awkward ERC20 symbol(), which is the reason the two are separable at all. # # Example: # seed-voucher-metadata.sh ~/.cbdcd/config/genesis.json \ @@ -36,11 +52,12 @@ set -euo pipefail GENESIS="${1:?genesis.json}" -CLIENT_ID="${2:?client id, e.g. qbftclient-0}" +CLIENT_ID="${2:?client id on THIS chain tracking the source, e.g. qbftclient-0}" TOKEN="${3:?0x token address on the spoke}" SYMBOL="${4:?symbol, e.g. tCeBM_BRL}" NAME="${5:?human name}" DECIMALS="${6:?decimals on the source chain}" +DISPLAY="${7:-$SYMBOL}" # solidity-ibc-eureka sets the ICS-20 denom with Strings.toHexString(address), # which is lowercase. The trace and therefore the hash depend on that exactly. @@ -53,20 +70,22 @@ echo "trace $TRACE" echo "denom $DENOM" TMP=$(mktemp) -jq --arg denom "$DENOM" --arg sym "$SYMBOL" --arg name "$NAME" \ +# x/bank requires `display` to name one of the denom_units, so the display unit +# carries $disp; `symbol` is the ticker and stays independent of it. +jq --arg denom "$DENOM" --arg sym "$SYMBOL" --arg disp "$DISPLAY" --arg name "$NAME" \ --arg trace "$TRACE" --argjson exp "$DECIMALS" ' .app_state.bank.denom_metadata += [{ description: ("Voucher for " + $sym + " received over " + $trace), denom_units: [ { denom: $denom, exponent: 0, aliases: [] }, - { denom: $sym, exponent: $exp, aliases: [] } + { denom: $disp, exponent: $exp, aliases: [] } ], base: $denom, - display: $sym, + display: $disp, name: $name, symbol: $sym, uri: "", uri_hash: "" }]' "$GENESIS" > "$TMP" && mv "$TMP" "$GENESIS" -echo "seeded display=$SYMBOL symbol=$SYMBOL name=$NAME exponent=$DECIMALS" +echo "seeded display=$DISPLAY symbol=$SYMBOL name=$NAME exponent=$DECIMALS" From 84db8da54d28de91a45410a5a5a0953636cfab59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 7 Aug 2026 14:57:15 +0200 Subject: [PATCH 43/61] feat(corridor): autorelay loop, Scenario B relayer config, and ignore per-run state autorelay.sh drives both directions off persisted height cursors under STATE_ROOT; relayer-config.scenb.yml points cosmos/ibc-relayer at the Scenario B hub leg. Both write their state under $PWD/.corridor, which is the repo root whenever the corridor is driven from here, so it is ignored: logs, the attestor seen-set and relay height cursors are per-run, not source. Co-Authored-By: Claude Opus 5 --- .gitignore | 11 ++- scripts/corridor/autorelay.sh | 90 ++++++++++++++++++ scripts/corridor/relayer-config.scenb.yml | 108 ++++++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 scripts/corridor/autorelay.sh create mode 100644 scripts/corridor/relayer-config.scenb.yml diff --git a/.gitignore b/.gitignore index 79d72151..a1141193 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,14 @@ release/ bin/ .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 -scripts/corridor/relayer-keys.json +# 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 diff --git a/scripts/corridor/autorelay.sh b/scripts/corridor/autorelay.sh new file mode 100644 index 00000000..1f97a90c --- /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}" +CBDCD="${CBDCD:-bin/cbdcd}" +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=$("$CBDCD" 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/relayer-config.scenb.yml b/scripts/corridor/relayer-config.scenb.yml new file mode 100644 index 00000000..2631f766 --- /dev/null +++ b/scripts/corridor/relayer-config.scenb.yml @@ -0,0 +1,108 @@ +# 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: +# bin/relayer --config scripts/corridor/relayer-config.scenb.yml +# with qbftattestor and qbftproofapi already up. + +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 -- see relayer-keys.example.json. + keys_path: 'scripts/corridor/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: '0xb7AA3cb25020F302b5F7cB6B98dd5722B345b1ac' + ics_20_transfer_address: '0xF51939C25Eb80F86088EE95251F5fd14e49A4d71' + tx_submission_delay: 0s From 1bf22a5521079b019acf851aff2918e1c326ff0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 17 Aug 2026 11:31:34 +0200 Subject: [PATCH 44/61] feat(attestor): a Signer seam, and speak the attestor's own protocol --- cmd/qbftattestor/attestation_service.go | 152 ++++++ cmd/qbftattestor/cbdc_test.go | 14 +- cmd/qbftattestor/grpc.go | 9 +- cmd/qbftattestor/main.go | 55 ++- proto/ibc_attestor/attestation.proto | 26 + proto/ibc_attestor/ibc_attestor.proto | 74 +++ x/qbftclient/attestor/attestation.go | 22 +- x/qbftclient/attestor/attestation_test.go | 6 +- .../attestor/attestorpb/attestation.pb.go | 166 +++++++ .../attestor/attestorpb/ibc_attestor.pb.go | 455 ++++++++++++++++++ x/qbftclient/attestor/attestorpb/service.go | 142 ++++++ x/qbftclient/attestor/packetabi.go | 127 +++++ x/qbftclient/attestor/packetabi_test.go | 81 ++++ x/qbftclient/attestor/signer.go | 98 ++++ x/qbftclient/attestor/signer_test.go | 87 ++++ 15 files changed, 1478 insertions(+), 36 deletions(-) create mode 100644 cmd/qbftattestor/attestation_service.go create mode 100644 proto/ibc_attestor/attestation.proto create mode 100644 proto/ibc_attestor/ibc_attestor.proto create mode 100644 x/qbftclient/attestor/attestorpb/attestation.pb.go create mode 100644 x/qbftclient/attestor/attestorpb/ibc_attestor.pb.go create mode 100644 x/qbftclient/attestor/attestorpb/service.go create mode 100644 x/qbftclient/attestor/packetabi.go create mode 100644 x/qbftclient/attestor/packetabi_test.go create mode 100644 x/qbftclient/attestor/signer.go create mode 100644 x/qbftclient/attestor/signer_test.go diff --git a/cmd/qbftattestor/attestation_service.go b/cmd/qbftattestor/attestation_service.go new file mode 100644 index 00000000..2af6d868 --- /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_test.go b/cmd/qbftattestor/cbdc_test.go index 89e50fc0..57703c0f 100644 --- a/cmd/qbftattestor/cbdc_test.go +++ b/cmd/qbftattestor/cbdc_test.go @@ -113,7 +113,7 @@ func TestProvenAbsent_RefusesEmptyAnswerWithoutProof(t *testing.T) { } func TestProvenAbsent_RefusesFloatingHeightWithoutAsking(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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() @@ -139,7 +139,7 @@ func TestAttestAbsence_SignsTheNonMembershipShape(t *testing.T) { srv := fakeNode(t, provenEmpty, nil) key, err := crypto.GenerateKey() require.NoError(t, err) - s := &server{key: key, chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + 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() @@ -151,7 +151,7 @@ func TestAttestAbsence_SignsTheNonMembershipShape(t *testing.T) { } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) - want, err := attestor.PacketProof(key, 7, []attestor.PacketCompact{{ + want, err := attestor.PacketProof(attestor.NewLocalSigner(key), 7, []attestor.PacketCompact{{ Path: attestor.ReceiptPathHash("qbftclient-0", 3), // Commitment stays zero: that IS the absence claim. }}) @@ -165,7 +165,7 @@ func TestAttestAbsence_RefusesWhenReceiptExists(t *testing.T) { srv := fakeNode(t, resp, nil) key, err := crypto.GenerateKey() require.NoError(t, err) - s := &server{key: key, chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + 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() @@ -180,7 +180,7 @@ 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: key, chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + 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() @@ -197,7 +197,7 @@ func TestAttestAck_SignsTheStoredCommitment(t *testing.T) { }, nil) key, err := crypto.GenerateKey() require.NoError(t, err) - s := &server{key: key, chain: &cbdcClient{rpc: srv.URL}, client: "qbftclient-0"} + 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() @@ -208,7 +208,7 @@ func TestAttestAck_SignsTheStoredCommitment(t *testing.T) { Proof string `json:"proof"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) - want, err := attestor.PacketProof(key, 7, []attestor.PacketCompact{{ + want, err := attestor.PacketProof(attestor.NewLocalSigner(key), 7, []attestor.PacketCompact{{ Path: attestor.AckPathHash("qbftclient-0", 3), Commitment: stored, }}) diff --git a/cmd/qbftattestor/grpc.go b/cmd/qbftattestor/grpc.go index bb256c1a..d5e4b430 100644 --- a/cmd/qbftattestor/grpc.go +++ b/cmd/qbftattestor/grpc.go @@ -10,6 +10,7 @@ import ( "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 @@ -114,6 +115,12 @@ func serveGRPC(addr string, s *server) error { } srv := grpc.NewServer() pb.RegisterAggregatorServiceServer(srv, &aggregatorServer{s: s}) - log.Printf("aggregator gRPC (AggregatorService) on %s", addr) + // 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 index 6255d70b..eb4d721a 100644 --- a/cmd/qbftattestor/main.go +++ b/cmd/qbftattestor/main.go @@ -1,25 +1,25 @@ // Command qbftattestor is the attestor sidecar for the outbound corridor leg. // -// WHAT IT IS FOR +// # 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 +// # 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 theatre -- the signature would +// 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 +// # FREEZE SAFETY // // Signing two different timestamps for one height freezes the client // permanently, with no unfreeze. Binding the signature itself to a chain @@ -47,7 +47,6 @@ package main import ( "context" - "crypto/ecdsa" "encoding/hex" "encoding/json" "errors" @@ -57,6 +56,7 @@ import ( "net/http" "os" "sync" + "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -65,7 +65,9 @@ import ( ) type server struct { - key *ecdsa.PrivateKey + // 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 @@ -134,8 +136,12 @@ func main() { 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: key, + key: signer, chain: &cbdcClient{rpc: *rpc}, client: *clientID, seen: map[uint64]uint64{}, @@ -178,7 +184,7 @@ func main() { log.Fatalf("state: %v", err) } - log.Printf("attestor %s", crypto.PubkeyToAddress(key.PublicKey)) + 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) @@ -196,9 +202,20 @@ func main() { 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": crypto.PubkeyToAddress(key.PublicKey).Hex()}) + writeJSON(w, map[string]string{"address": signer.Address().Hex()}) }) - log.Fatal(http.ListenAndServe(*listen, nil)) + // 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 @@ -242,6 +259,15 @@ func (s *server) attestState(w http.ResponseWriter, r *http.Request) { // 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"` @@ -281,6 +307,15 @@ func (s *server) attestPacket(w http.ResponseWriter, r *http.Request) { // (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"` diff --git a/proto/ibc_attestor/attestation.proto b/proto/ibc_attestor/attestation.proto new file mode 100644 index 00000000..80dd5b6c --- /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 00000000..06714c1a --- /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/x/qbftclient/attestor/attestation.go b/x/qbftclient/attestor/attestation.go index 11787f37..c6e8abe8 100644 --- a/x/qbftclient/attestor/attestation.go +++ b/x/qbftclient/attestor/attestation.go @@ -41,7 +41,6 @@ package attestor import ( - "crypto/ecdsa" "crypto/sha256" "fmt" @@ -112,18 +111,11 @@ func Digest(data []byte, tag byte) [32]byte { // Sign produces the 65-byte r||s||v signature the contract expects. // -// go-ethereum emits v as 0/1; OpenZeppelin's ECDSA.recover requires 27/28 and -// rejects anything else, so the shift is mandatory rather than cosmetic. -func Sign(key *ecdsa.PrivateKey, digest [32]byte) ([]byte, error) { - sig, err := crypto.Sign(digest[:], key) - if err != nil { - return nil, fmt.Errorf("sign: %w", err) - } - if len(sig) != 65 { - return nil, fmt.Errorf("expected 65-byte signature, got %d", len(sig)) - } - sig[64] += 27 - return sig, nil +// The 27/28 shift and the key custody both live in the Signer implementation +// now (see signer.go) -- this stays as the package's signing entry point so the +// call sites read the same as they always did. +func Sign(s Signer, digest [32]byte) ([]byte, error) { + return s.Sign(digest) } // EncodeProof returns abi.encode(AttestationProof{data, signatures}), which is @@ -137,7 +129,7 @@ func EncodeProof(data []byte, signatures [][]byte) ([]byte, error) { } // StateProof builds a signed update-client message for (height, timestamp). -func StateProof(key *ecdsa.PrivateKey, height, timestamp uint64) ([]byte, error) { +func StateProof(key Signer, height, timestamp uint64) ([]byte, error) { data, err := EncodeState(height, timestamp) if err != nil { return nil, fmt.Errorf("encode state: %w", err) @@ -150,7 +142,7 @@ func StateProof(key *ecdsa.PrivateKey, height, timestamp uint64) ([]byte, error) } // PacketProof builds a signed membership proof for the given packets at height. -func PacketProof(key *ecdsa.PrivateKey, height uint64, packets []PacketCompact) ([]byte, error) { +func PacketProof(key Signer, height uint64, packets []PacketCompact) ([]byte, error) { data, err := EncodePackets(height, packets) if err != nil { return nil, fmt.Errorf("encode packets: %w", err) diff --git a/x/qbftclient/attestor/attestation_test.go b/x/qbftclient/attestor/attestation_test.go index 7e95ccf5..1bd96135 100644 --- a/x/qbftclient/attestor/attestation_test.go +++ b/x/qbftclient/attestor/attestation_test.go @@ -67,7 +67,7 @@ func TestSign_UsesEthereumVRange(t *testing.T) { key, err := crypto.GenerateKey() require.NoError(t, err) - sig, err := Sign(key, Digest([]byte("x"), TagState)) + sig, err := Sign(NewLocalSigner(key), Digest([]byte("x"), TagState)) require.NoError(t, err) require.Len(t, sig, 65) require.Contains(t, []byte{27, 28}, sig[64]) @@ -79,7 +79,7 @@ func TestSign_RecoversToTheAttestorAddress(t *testing.T) { want := crypto.PubkeyToAddress(key.PublicKey) digest := Digest([]byte("attestation"), TagPacket) - sig, err := Sign(key, digest) + sig, err := Sign(NewLocalSigner(key), digest) require.NoError(t, err) // Undo the OZ shift the way the contract's recover does. @@ -126,7 +126,7 @@ func TestPacketProof_RoundTrips(t *testing.T) { key, err := crypto.GenerateKey() require.NoError(t, err) - proof, err := PacketProof(key, 42, []PacketCompact{{ + proof, err := PacketProof(NewLocalSigner(key), 42, []PacketCompact{{ Path: PathHash("qbftclient-0", 1), Commitment: [32]byte{0xac, 0x17}, }}) diff --git a/x/qbftclient/attestor/attestorpb/attestation.pb.go b/x/qbftclient/attestor/attestorpb/attestation.pb.go new file mode 100644 index 00000000..285e1038 --- /dev/null +++ b/x/qbftclient/attestor/attestorpb/attestation.pb.go @@ -0,0 +1,166 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v5.29.3 +// source: ibc_attestor/attestation.proto + +package attestorpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// 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. +type Attestation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The height of the attestation + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + // The timestamp of the block + Timestamp *uint64 `protobuf:"varint,2,opt,name=timestamp,proto3,oneof" json:"timestamp,omitempty"` + // The attested data + AttestedData []byte `protobuf:"bytes,3,opt,name=attested_data,json=attestedData,proto3" json:"attested_data,omitempty"` + // The attestation signature + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Attestation) Reset() { + *x = Attestation{} + mi := &file_ibc_attestor_attestation_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Attestation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Attestation) ProtoMessage() {} + +func (x *Attestation) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_attestation_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Attestation.ProtoReflect.Descriptor instead. +func (*Attestation) Descriptor() ([]byte, []int) { + return file_ibc_attestor_attestation_proto_rawDescGZIP(), []int{0} +} + +func (x *Attestation) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *Attestation) GetTimestamp() uint64 { + if x != nil && x.Timestamp != nil { + return *x.Timestamp + } + return 0 +} + +func (x *Attestation) GetAttestedData() []byte { + if x != nil { + return x.AttestedData + } + return nil +} + +func (x *Attestation) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +var File_ibc_attestor_attestation_proto protoreflect.FileDescriptor + +const file_ibc_attestor_attestation_proto_rawDesc = "" + + "\n" + + "\x1eibc_attestor/attestation.proto\x12\fibc_attestor\"\x99\x01\n" + + "\vAttestation\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height\x12!\n" + + "\ttimestamp\x18\x02 \x01(\x04H\x00R\ttimestamp\x88\x01\x01\x12#\n" + + "\rattested_data\x18\x03 \x01(\fR\fattestedData\x12\x1c\n" + + "\tsignature\x18\x04 \x01(\fR\tsignatureB\f\n" + + "\n" + + "_timestampB@Z>github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpbb\x06proto3" + +var ( + file_ibc_attestor_attestation_proto_rawDescOnce sync.Once + file_ibc_attestor_attestation_proto_rawDescData []byte +) + +func file_ibc_attestor_attestation_proto_rawDescGZIP() []byte { + file_ibc_attestor_attestation_proto_rawDescOnce.Do(func() { + file_ibc_attestor_attestation_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ibc_attestor_attestation_proto_rawDesc), len(file_ibc_attestor_attestation_proto_rawDesc))) + }) + return file_ibc_attestor_attestation_proto_rawDescData +} + +var file_ibc_attestor_attestation_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_ibc_attestor_attestation_proto_goTypes = []any{ + (*Attestation)(nil), // 0: ibc_attestor.Attestation +} +var file_ibc_attestor_attestation_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_ibc_attestor_attestation_proto_init() } +func file_ibc_attestor_attestation_proto_init() { + if File_ibc_attestor_attestation_proto != nil { + return + } + file_ibc_attestor_attestation_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ibc_attestor_attestation_proto_rawDesc), len(file_ibc_attestor_attestation_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_ibc_attestor_attestation_proto_goTypes, + DependencyIndexes: file_ibc_attestor_attestation_proto_depIdxs, + MessageInfos: file_ibc_attestor_attestation_proto_msgTypes, + }.Build() + File_ibc_attestor_attestation_proto = out.File + file_ibc_attestor_attestation_proto_goTypes = nil + file_ibc_attestor_attestation_proto_depIdxs = nil +} diff --git a/x/qbftclient/attestor/attestorpb/ibc_attestor.pb.go b/x/qbftclient/attestor/attestorpb/ibc_attestor.pb.go new file mode 100644 index 00000000..257725e2 --- /dev/null +++ b/x/qbftclient/attestor/attestorpb/ibc_attestor.pb.go @@ -0,0 +1,455 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v5.29.3 +// source: ibc_attestor/ibc_attestor.proto + +package attestorpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Commitment type for packet attestation +type CommitmentType int32 + +const ( + // Packet commitment (for SendPacket events) + CommitmentType_COMMITMENT_TYPE_PACKET CommitmentType = 0 + // Acknowledgment commitment (for WriteAcknowledgement events) + CommitmentType_COMMITMENT_TYPE_ACK CommitmentType = 1 + // Receipt commitment (for Timeout events - non-membership proof) + CommitmentType_COMMITMENT_TYPE_RECEIPT CommitmentType = 2 +) + +// Enum value maps for CommitmentType. +var ( + CommitmentType_name = map[int32]string{ + 0: "COMMITMENT_TYPE_PACKET", + 1: "COMMITMENT_TYPE_ACK", + 2: "COMMITMENT_TYPE_RECEIPT", + } + CommitmentType_value = map[string]int32{ + "COMMITMENT_TYPE_PACKET": 0, + "COMMITMENT_TYPE_ACK": 1, + "COMMITMENT_TYPE_RECEIPT": 2, + } +) + +func (x CommitmentType) Enum() *CommitmentType { + p := new(CommitmentType) + *p = x + return p +} + +func (x CommitmentType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CommitmentType) Descriptor() protoreflect.EnumDescriptor { + return file_ibc_attestor_ibc_attestor_proto_enumTypes[0].Descriptor() +} + +func (CommitmentType) Type() protoreflect.EnumType { + return &file_ibc_attestor_ibc_attestor_proto_enumTypes[0] +} + +func (x CommitmentType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CommitmentType.Descriptor instead. +func (CommitmentType) EnumDescriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{0} +} + +// Request message for getting an attestation for a state at a given height. +type StateAttestationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The height to attest to + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateAttestationRequest) Reset() { + *x = StateAttestationRequest{} + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateAttestationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateAttestationRequest) ProtoMessage() {} + +func (x *StateAttestationRequest) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateAttestationRequest.ProtoReflect.Descriptor instead. +func (*StateAttestationRequest) Descriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{0} +} + +func (x *StateAttestationRequest) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +// Response message for getting an attestation for a state at a given height. +type StateAttestationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The attestation + Attestation *Attestation `protobuf:"bytes,1,opt,name=attestation,proto3" json:"attestation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateAttestationResponse) Reset() { + *x = StateAttestationResponse{} + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateAttestationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateAttestationResponse) ProtoMessage() {} + +func (x *StateAttestationResponse) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateAttestationResponse.ProtoReflect.Descriptor instead. +func (*StateAttestationResponse) Descriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{1} +} + +func (x *StateAttestationResponse) GetAttestation() *Attestation { + if x != nil { + return x.Attestation + } + return nil +} + +// Request message for getting an attestation for a set of packets. +type PacketAttestationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The packets to attest to + Packets [][]byte `protobuf:"bytes,1,rep,name=packets,proto3" json:"packets,omitempty"` + // The height to attest to the packets at + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + // The type of commitment to attest (packet or acknowledgment) + // Defaults to COMMITMENT_TYPE_PACKET if not specified (for backward compatibility) + CommitmentType CommitmentType `protobuf:"varint,3,opt,name=commitment_type,json=commitmentType,proto3,enum=ibc_attestor.CommitmentType" json:"commitment_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PacketAttestationRequest) Reset() { + *x = PacketAttestationRequest{} + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PacketAttestationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PacketAttestationRequest) ProtoMessage() {} + +func (x *PacketAttestationRequest) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PacketAttestationRequest.ProtoReflect.Descriptor instead. +func (*PacketAttestationRequest) Descriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{2} +} + +func (x *PacketAttestationRequest) GetPackets() [][]byte { + if x != nil { + return x.Packets + } + return nil +} + +func (x *PacketAttestationRequest) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *PacketAttestationRequest) GetCommitmentType() CommitmentType { + if x != nil { + return x.CommitmentType + } + return CommitmentType_COMMITMENT_TYPE_PACKET +} + +// Response message for getting an attestation for a set of packets. +type PacketAttestationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The attestation + Attestation *Attestation `protobuf:"bytes,1,opt,name=attestation,proto3" json:"attestation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PacketAttestationResponse) Reset() { + *x = PacketAttestationResponse{} + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PacketAttestationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PacketAttestationResponse) ProtoMessage() {} + +func (x *PacketAttestationResponse) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PacketAttestationResponse.ProtoReflect.Descriptor instead. +func (*PacketAttestationResponse) Descriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{3} +} + +func (x *PacketAttestationResponse) GetAttestation() *Attestation { + if x != nil { + return x.Attestation + } + return nil +} + +// Request message for getting the latest height. +type LatestHeightRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LatestHeightRequest) Reset() { + *x = LatestHeightRequest{} + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LatestHeightRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LatestHeightRequest) ProtoMessage() {} + +func (x *LatestHeightRequest) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LatestHeightRequest.ProtoReflect.Descriptor instead. +func (*LatestHeightRequest) Descriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{4} +} + +// Response message for getting the latest height. +type LatestHeightResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The latest height of the attested chain + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LatestHeightResponse) Reset() { + *x = LatestHeightResponse{} + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LatestHeightResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LatestHeightResponse) ProtoMessage() {} + +func (x *LatestHeightResponse) ProtoReflect() protoreflect.Message { + mi := &file_ibc_attestor_ibc_attestor_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LatestHeightResponse.ProtoReflect.Descriptor instead. +func (*LatestHeightResponse) Descriptor() ([]byte, []int) { + return file_ibc_attestor_ibc_attestor_proto_rawDescGZIP(), []int{5} +} + +func (x *LatestHeightResponse) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +var File_ibc_attestor_ibc_attestor_proto protoreflect.FileDescriptor + +const file_ibc_attestor_ibc_attestor_proto_rawDesc = "" + + "\n" + + "\x1fibc_attestor/ibc_attestor.proto\x12\fibc_attestor\x1a\x1eibc_attestor/attestation.proto\"1\n" + + "\x17StateAttestationRequest\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height\"W\n" + + "\x18StateAttestationResponse\x12;\n" + + "\vattestation\x18\x01 \x01(\v2\x19.ibc_attestor.AttestationR\vattestation\"\x93\x01\n" + + "\x18PacketAttestationRequest\x12\x18\n" + + "\apackets\x18\x01 \x03(\fR\apackets\x12\x16\n" + + "\x06height\x18\x02 \x01(\x04R\x06height\x12E\n" + + "\x0fcommitment_type\x18\x03 \x01(\x0e2\x1c.ibc_attestor.CommitmentTypeR\x0ecommitmentType\"X\n" + + "\x19PacketAttestationResponse\x12;\n" + + "\vattestation\x18\x01 \x01(\v2\x19.ibc_attestor.AttestationR\vattestation\"\x15\n" + + "\x13LatestHeightRequest\".\n" + + "\x14LatestHeightResponse\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height*b\n" + + "\x0eCommitmentType\x12\x1a\n" + + "\x16COMMITMENT_TYPE_PACKET\x10\x00\x12\x17\n" + + "\x13COMMITMENT_TYPE_ACK\x10\x01\x12\x1b\n" + + "\x17COMMITMENT_TYPE_RECEIPT\x10\x022\xb4\x02\n" + + "\x12AttestationService\x12a\n" + + "\x10StateAttestation\x12%.ibc_attestor.StateAttestationRequest\x1a&.ibc_attestor.StateAttestationResponse\x12d\n" + + "\x11PacketAttestation\x12&.ibc_attestor.PacketAttestationRequest\x1a'.ibc_attestor.PacketAttestationResponse\x12U\n" + + "\fLatestHeight\x12!.ibc_attestor.LatestHeightRequest\x1a\".ibc_attestor.LatestHeightResponseB@Z>github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpbb\x06proto3" + +var ( + file_ibc_attestor_ibc_attestor_proto_rawDescOnce sync.Once + file_ibc_attestor_ibc_attestor_proto_rawDescData []byte +) + +func file_ibc_attestor_ibc_attestor_proto_rawDescGZIP() []byte { + file_ibc_attestor_ibc_attestor_proto_rawDescOnce.Do(func() { + file_ibc_attestor_ibc_attestor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ibc_attestor_ibc_attestor_proto_rawDesc), len(file_ibc_attestor_ibc_attestor_proto_rawDesc))) + }) + return file_ibc_attestor_ibc_attestor_proto_rawDescData +} + +var file_ibc_attestor_ibc_attestor_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_ibc_attestor_ibc_attestor_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_ibc_attestor_ibc_attestor_proto_goTypes = []any{ + (CommitmentType)(0), // 0: ibc_attestor.CommitmentType + (*StateAttestationRequest)(nil), // 1: ibc_attestor.StateAttestationRequest + (*StateAttestationResponse)(nil), // 2: ibc_attestor.StateAttestationResponse + (*PacketAttestationRequest)(nil), // 3: ibc_attestor.PacketAttestationRequest + (*PacketAttestationResponse)(nil), // 4: ibc_attestor.PacketAttestationResponse + (*LatestHeightRequest)(nil), // 5: ibc_attestor.LatestHeightRequest + (*LatestHeightResponse)(nil), // 6: ibc_attestor.LatestHeightResponse + (*Attestation)(nil), // 7: ibc_attestor.Attestation +} +var file_ibc_attestor_ibc_attestor_proto_depIdxs = []int32{ + 7, // 0: ibc_attestor.StateAttestationResponse.attestation:type_name -> ibc_attestor.Attestation + 0, // 1: ibc_attestor.PacketAttestationRequest.commitment_type:type_name -> ibc_attestor.CommitmentType + 7, // 2: ibc_attestor.PacketAttestationResponse.attestation:type_name -> ibc_attestor.Attestation + 1, // 3: ibc_attestor.AttestationService.StateAttestation:input_type -> ibc_attestor.StateAttestationRequest + 3, // 4: ibc_attestor.AttestationService.PacketAttestation:input_type -> ibc_attestor.PacketAttestationRequest + 5, // 5: ibc_attestor.AttestationService.LatestHeight:input_type -> ibc_attestor.LatestHeightRequest + 2, // 6: ibc_attestor.AttestationService.StateAttestation:output_type -> ibc_attestor.StateAttestationResponse + 4, // 7: ibc_attestor.AttestationService.PacketAttestation:output_type -> ibc_attestor.PacketAttestationResponse + 6, // 8: ibc_attestor.AttestationService.LatestHeight:output_type -> ibc_attestor.LatestHeightResponse + 6, // [6:9] is the sub-list for method output_type + 3, // [3:6] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_ibc_attestor_ibc_attestor_proto_init() } +func file_ibc_attestor_ibc_attestor_proto_init() { + if File_ibc_attestor_ibc_attestor_proto != nil { + return + } + file_ibc_attestor_attestation_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ibc_attestor_ibc_attestor_proto_rawDesc), len(file_ibc_attestor_ibc_attestor_proto_rawDesc)), + NumEnums: 1, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ibc_attestor_ibc_attestor_proto_goTypes, + DependencyIndexes: file_ibc_attestor_ibc_attestor_proto_depIdxs, + EnumInfos: file_ibc_attestor_ibc_attestor_proto_enumTypes, + MessageInfos: file_ibc_attestor_ibc_attestor_proto_msgTypes, + }.Build() + File_ibc_attestor_ibc_attestor_proto = out.File + file_ibc_attestor_ibc_attestor_proto_goTypes = nil + file_ibc_attestor_ibc_attestor_proto_depIdxs = nil +} diff --git a/x/qbftclient/attestor/attestorpb/service.go b/x/qbftclient/attestor/attestorpb/service.go new file mode 100644 index 00000000..0bd6b601 --- /dev/null +++ b/x/qbftclient/attestor/attestorpb/service.go @@ -0,0 +1,142 @@ +// This file is written by hand but mirrors protoc-gen-go-grpc's output shape, +// because the proto-builder image ships protoc-gen-go without the grpc plugin. +// That shape is the point: it must stay diffable against what the generator +// would emit, so the handler signatures keep `srv any` before `ctx` and the +// service descriptor keeps its generated `Xxx_ServiceDesc` name. revive objects +// to both; matching upstream codegen wins over house style here. +// +//nolint:revive,stylecheck // mirrors protoc-gen-go-grpc output; see note above +package attestorpb + +// gRPC glue for ibc_attestor.AttestationService, hand-written for the same +// reason aggregatorpb is: the proto-builder image ships protoc-gen-go but not +// protoc-gen-go-grpc. +// +// WHY THIS EXISTS ALONGSIDE aggregatorpb +// +// aggregator.AggregatorService is the THIN interface -- {packets, height} and +// nothing else. It cannot say "attest that this path is EMPTY", which is what a +// timeout needs, so serving it forced receipt absence onto a side-channel HTTP +// endpoint. AttestationService is the attestor's own interface and carries +// CommitmentType, so membership and non-membership travel the same path with +// the intent stated rather than implied. +// +// Both the client and the server halves are here on purpose. cmd/qbftproofapi +// speaks ONLY this protocol, and both sidecars serve it -- cosmos/ibc-attestor +// natively, cmd/qbftattestor via grpc.go. Swapping one for the other is then a +// change of address, not a change of code, in either direction. That matters +// most on the way BACK: a rollback that also needed a protocol revert would be +// a code change made under incident pressure. + +import ( + context "context" + + grpc "google.golang.org/grpc" +) + +const serviceName = "ibc_attestor.AttestationService" + +// AttestationServiceServer is the server API for AttestationService. +type AttestationServiceServer interface { + StateAttestation(context.Context, *StateAttestationRequest) (*StateAttestationResponse, error) + PacketAttestation(context.Context, *PacketAttestationRequest) (*PacketAttestationResponse, error) + LatestHeight(context.Context, *LatestHeightRequest) (*LatestHeightResponse, error) +} + +// AttestationServiceClient is the client API for AttestationService. +type AttestationServiceClient interface { + StateAttestation(ctx context.Context, in *StateAttestationRequest, opts ...grpc.CallOption) (*StateAttestationResponse, error) + PacketAttestation(ctx context.Context, in *PacketAttestationRequest, opts ...grpc.CallOption) (*PacketAttestationResponse, error) + LatestHeight(ctx context.Context, in *LatestHeightRequest, opts ...grpc.CallOption) (*LatestHeightResponse, error) +} + +type attestationServiceClient struct{ cc grpc.ClientConnInterface } + +// NewAttestationServiceClient returns a client for AttestationService. +func NewAttestationServiceClient(cc grpc.ClientConnInterface) AttestationServiceClient { + return &attestationServiceClient{cc} +} + +func (c *attestationServiceClient) StateAttestation(ctx context.Context, in *StateAttestationRequest, opts ...grpc.CallOption) (*StateAttestationResponse, error) { + out := new(StateAttestationResponse) + if err := c.cc.Invoke(ctx, "/"+serviceName+"/StateAttestation", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +func (c *attestationServiceClient) PacketAttestation(ctx context.Context, in *PacketAttestationRequest, opts ...grpc.CallOption) (*PacketAttestationResponse, error) { + out := new(PacketAttestationResponse) + if err := c.cc.Invoke(ctx, "/"+serviceName+"/PacketAttestation", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +func (c *attestationServiceClient) LatestHeight(ctx context.Context, in *LatestHeightRequest, opts ...grpc.CallOption) (*LatestHeightResponse, error) { + out := new(LatestHeightResponse) + if err := c.cc.Invoke(ctx, "/"+serviceName+"/LatestHeight", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +// RegisterAttestationServiceServer registers an implementation with a gRPC server. +func RegisterAttestationServiceServer(s grpc.ServiceRegistrar, srv AttestationServiceServer) { + s.RegisterService(&AttestationService_ServiceDesc, srv) +} + +func handlerStateAttestation(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(StateAttestationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AttestationServiceServer).StateAttestation(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/StateAttestation"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(AttestationServiceServer).StateAttestation(ctx, req.(*StateAttestationRequest)) + }) +} + +func handlerPacketAttestation(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(PacketAttestationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AttestationServiceServer).PacketAttestation(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/PacketAttestation"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(AttestationServiceServer).PacketAttestation(ctx, req.(*PacketAttestationRequest)) + }) +} + +func handlerLatestHeight(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(LatestHeightRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AttestationServiceServer).LatestHeight(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/" + serviceName + "/LatestHeight"} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(AttestationServiceServer).LatestHeight(ctx, req.(*LatestHeightRequest)) + }) +} + +// AttestationService_ServiceDesc is the grpc.ServiceDesc for AttestationService. +var AttestationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: serviceName, + HandlerType: (*AttestationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + {MethodName: "StateAttestation", Handler: handlerStateAttestation}, + {MethodName: "PacketAttestation", Handler: handlerPacketAttestation}, + {MethodName: "LatestHeight", Handler: handlerLatestHeight}, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ibc_attestor/ibc_attestor.proto", +} diff --git a/x/qbftclient/attestor/packetabi.go b/x/qbftclient/attestor/packetabi.go new file mode 100644 index 00000000..82c957a3 --- /dev/null +++ b/x/qbftclient/attestor/packetabi.go @@ -0,0 +1,127 @@ +package attestor + +// ABI codec for the Solidity `IICS26RouterMsgs.Packet` struct, plus the rule +// that turns a packet into the ICS-24 path a given commitment kind lives at. +// +// WHY THE WIRE CARRIES PACKETS AND NOT PATHS +// +// aggregator.AggregatorService takes raw path bytes: the caller says which key +// to read, and the attestor reads it. That is safe for the VALUE -- the value +// is always read from our own node -- but it trusts the caller for the KEY, and +// the key encodes which client and which kind. A caller that asks for a receipt +// path while the server believes it is attesting a commitment gets a signature +// over the wrong claim. +// +// ibc_attestor.AttestationService closes that: the request carries the PACKET +// and a CommitmentType, and the attestor derives the path itself. The caller +// can no longer choose the key at all. Deriving it here rather than accepting +// it is the whole point, so PathForCommitmentType takes a packet, never a path. +// +// The encoding must match `Packet::abi_decode` in cosmos/ibc-attestor exactly: +// each packets[] element is its own abi.encode of one Packet tuple, NOT one +// encoding of a Packet[] array. + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// SolPayload mirrors IICS26RouterMsgs.Payload field order. go-ethereum's packer +// matches struct fields to tuple components by name. +type SolPayload struct { + SourcePort string + DestPort string + Version string + Encoding string + Value []byte +} + +// SolPacket mirrors IICS26RouterMsgs.Packet field order. +type SolPacket struct { + Sequence uint64 + SourceClient string + DestClient string + TimeoutTimestamp uint64 + Payloads []SolPayload +} + +// CommitmentKind selects which ICS-24 store a packet's path refers to. The +// values match ibc_attestor.CommitmentType so the two never drift apart. +type CommitmentKind int32 + +const ( + // CommitmentKindPacket is the send commitment, written by the SOURCE. + CommitmentKindPacket CommitmentKind = 0 + // CommitmentKindAck is the acknowledgement, written by the DESTINATION. + CommitmentKindAck CommitmentKind = 1 + // CommitmentKindReceipt is the receipt, written by the DESTINATION, and the + // only kind whose ABSENCE is what gets attested. + CommitmentKindReceipt CommitmentKind = 2 +) + +var packetArgs = func() abi.Arguments { + payload := []abi.ArgumentMarshaling{ + {Name: "sourcePort", Type: "string"}, + {Name: "destPort", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "encoding", Type: "string"}, + {Name: "value", Type: "bytes"}, + } + packet, err := 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: payload}, + }) + if err != nil { + panic(fmt.Sprintf("attestor: building Packet abi type: %v", err)) + } + return abi.Arguments{{Type: packet}} +}() + +// EncodePacket returns abi.encode(Packet) for one packet, the form each element +// of PacketAttestationRequest.packets takes. +func EncodePacket(p SolPacket) ([]byte, error) { + return packetArgs.Pack(p) +} + +// DecodePacket is the inverse, for the server side of the same interface. +func DecodePacket(bz []byte) (SolPacket, error) { + values, err := packetArgs.Unpack(bz) + if err != nil { + return SolPacket{}, fmt.Errorf("attestor: decoding Packet: %w", err) + } + var out SolPacket + // go-ethereum decodes a tuple into an anonymous struct, so round-trip + // through the argument set's Copy to land in the named type. + if err := packetArgs.Copy(&struct{ Packet *SolPacket }{Packet: &out}, values); err != nil { + return SolPacket{}, fmt.Errorf("attestor: copying Packet: %w", err) + } + return out, nil +} + +// PathForCommitmentType returns the full ICS-24 path this packet's commitment +// of the given kind lives at. +// +// 🔴 The client id differs by kind and getting it wrong is silent: the send +// commitment is keyed by the packet's SOURCE client (the sender wrote it), +// while the ack and the receipt are keyed by its DESTINATION client (the +// receiver wrote them). Attesting a commitment under the destination's id +// reads an empty slot and looks like a missing packet; attesting a receipt +// under the source's id reads an empty slot and would attest ABSENCE of +// something that was never keyed there -- releasing escrow for a packet that +// may well have been delivered. +func PathForCommitmentType(p SolPacket, kind CommitmentKind) ([]byte, error) { + switch kind { + case CommitmentKindPacket: + return CommitmentPath(p.SourceClient, p.Sequence), nil + case CommitmentKindAck: + return AckPath(p.DestClient, p.Sequence), nil + case CommitmentKindReceipt: + return ReceiptPath(p.DestClient, p.Sequence), nil + default: + return nil, fmt.Errorf("attestor: unknown commitment type %d", kind) + } +} diff --git a/x/qbftclient/attestor/packetabi_test.go b/x/qbftclient/attestor/packetabi_test.go new file mode 100644 index 00000000..0afcb4e2 --- /dev/null +++ b/x/qbftclient/attestor/packetabi_test.go @@ -0,0 +1,81 @@ +package attestor + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func samplePacket() SolPacket { + return SolPacket{ + Sequence: 7, + SourceClient: "client-0", + DestClient: "qbftclient-0", + TimeoutTimestamp: 1786700000, + Payloads: []SolPayload{{ + SourcePort: "transfer", DestPort: "transfer", + Version: "ics20-1", Encoding: "application/x-solidity-abi", + Value: []byte{0xde, 0xad, 0xbe, 0xef}, + }}, + } +} + +func TestEncodePacketRoundTrip(t *testing.T) { + in := samplePacket() + bz, err := EncodePacket(in) + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := DecodePacket(bz) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out.Sequence != in.Sequence || out.SourceClient != in.SourceClient || + out.DestClient != in.DestClient || out.TimeoutTimestamp != in.TimeoutTimestamp { + t.Fatalf("header mismatch: got %+v want %+v", out, in) + } + if len(out.Payloads) != 1 || !bytes.Equal(out.Payloads[0].Value, in.Payloads[0].Value) || + out.Payloads[0].Encoding != in.Payloads[0].Encoding { + t.Fatalf("payload mismatch: got %+v", out.Payloads) + } +} + +// TestPathForCommitmentType_ClientKeying pins the rule that differs per kind and +// is silent when wrong: the send commitment is keyed by the SOURCE client, the +// ack and receipt by the DESTINATION. Attesting a receipt under the source's id +// would read an empty slot and attest ABSENCE of something never keyed there -- +// releasing escrow for a packet that may have been delivered. +func TestPathForCommitmentType_ClientKeying(t *testing.T) { + p := samplePacket() + for _, tc := range []struct { + kind CommitmentKind + want string + }{ + {CommitmentKindPacket, hex.EncodeToString(CommitmentPath("client-0", 7))}, + {CommitmentKindAck, hex.EncodeToString(AckPath("qbftclient-0", 7))}, + {CommitmentKindReceipt, hex.EncodeToString(ReceiptPath("qbftclient-0", 7))}, + } { + got, err := PathForCommitmentType(p, tc.kind) + if err != nil { + t.Fatalf("kind %d: %v", tc.kind, err) + } + if hex.EncodeToString(got) != tc.want { + t.Errorf("kind %d: got %x want %s", tc.kind, got, tc.want) + } + } +} + +// The numeric values must equal ibc_attestor.CommitmentType's, because the two +// are converted into one another without a mapping table. +func TestCommitmentKindValuesMatchProto(t *testing.T) { + if CommitmentKindPacket != 0 || CommitmentKindAck != 1 || CommitmentKindReceipt != 2 { + t.Fatalf("CommitmentKind values drifted from the proto enum: %d %d %d", + CommitmentKindPacket, CommitmentKindAck, CommitmentKindReceipt) + } +} + +func TestPathForCommitmentType_RejectsUnknown(t *testing.T) { + if _, err := PathForCommitmentType(samplePacket(), CommitmentKind(9)); err == nil { + t.Fatal("expected an error for an unknown commitment kind") + } +} diff --git a/x/qbftclient/attestor/signer.go b/x/qbftclient/attestor/signer.go new file mode 100644 index 00000000..785b3e5f --- /dev/null +++ b/x/qbftclient/attestor/signer.go @@ -0,0 +1,98 @@ +package attestor + +// Signer decouples "produce the 65-byte signature the contract expects" from +// "hold an ecdsa.PrivateKey in this process". +// +// The interface exists for KMS/HSM custody: a CBDC attestor key must not be a +// hex string in a flag, and the attestor is the ONLY component in the corridor +// that still requires one (cosmos/ibc-relayer already supports remote signing). +// +// Address() is part of the contract rather than derived by the caller because a +// remote signer has no public key to derive from -- the address comes from the +// backend's own metadata, and up-corridor.sh's identity probe compares against +// /address, which is served from exactly this method. + +import ( + "crypto/ecdsa" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" +) + +// Signer produces attestation signatures. Sign returns 65 bytes, r||s||v, with +// v already shifted to 27/28 -- the exact shape AttestationLightClient's +// ECDSA.recover accepts. +type Signer interface { + Sign(digest [32]byte) ([]byte, error) + Address() common.Address +} + +// LocalSigner holds the key in process. This is what every deployment uses +// today and what the devnet will keep using; it is not deprecated, it is simply +// no longer the only option. +type LocalSigner struct{ key *ecdsa.PrivateKey } + +func NewLocalSigner(key *ecdsa.PrivateKey) *LocalSigner { return &LocalSigner{key: key} } + +func (s *LocalSigner) Address() common.Address { return crypto.PubkeyToAddress(s.key.PublicKey) } + +func (s *LocalSigner) Sign(digest [32]byte) ([]byte, error) { + sig, err := crypto.Sign(digest[:], s.key) + if err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + if len(sig) != 65 { + return nil, fmt.Errorf("expected 65-byte signature, got %d", len(sig)) + } + // go-ethereum emits v as 0/1; OpenZeppelin's ECDSA.recover requires 27/28 + // and rejects anything else, so the shift is mandatory rather than cosmetic. + sig[64] += 27 + return sig, nil +} + +// halfN is secp256k1n/2. OpenZeppelin's ECDSA.recover REVERTS on s above it +// (ECDSAInvalidSignatureS), so a high-s signature is not merely non-canonical +// here -- it fails on chain. +var halfN = new(big.Int).Rsh(secp256k1.S256().Params().N, 1) + +// NormalizeRemote turns a raw (r, s) pair from an external signer into the +// 65-byte form the contract accepts, given the digest and the address the +// backend claims to sign for. +// +// Remote signers are why this exists and why it is not trivial: +// +// - Cloud KMS returns (r, s) with NO recovery id. v cannot be read off the +// signature; it has to be found by trying both parities and keeping the one +// that recovers the expected address. +// - KMS may legitimately return HIGH-s. secp256k1 signatures are malleable, +// so (r, s) and (r, n-s) are both valid -- but the contract accepts only +// the low-s form. Canonicalising must happen BEFORE the recovery search, +// because flipping s also flips the parity that recovers correctly. +// +// Both failures present as "unknown signer" on chain, indistinguishable from a +// wrong key, which is why this is a shared helper rather than each backend's +// problem. +func NormalizeRemote(digest [32]byte, r, s *big.Int, want common.Address) ([]byte, error) { + if s.Cmp(halfN) > 0 { + s = new(big.Int).Sub(secp256k1.S256().Params().N, s) + } + sig := make([]byte, 65) + r.FillBytes(sig[0:32]) + s.FillBytes(sig[32:64]) + + for v := byte(0); v <= 1; v++ { + sig[64] = v + pub, err := crypto.SigToPub(digest[:], sig) + if err != nil { + continue + } + if crypto.PubkeyToAddress(*pub) == want { + sig[64] = v + 27 + return sig, nil + } + } + return nil, fmt.Errorf("no recovery id recovers %s: the backend signed a different digest, or it is not the key it claims to be", want) +} diff --git a/x/qbftclient/attestor/signer_test.go b/x/qbftclient/attestor/signer_test.go new file mode 100644 index 00000000..d0abc4cf --- /dev/null +++ b/x/qbftclient/attestor/signer_test.go @@ -0,0 +1,87 @@ +package attestor + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" +) + +// A remote backend returns (r, s) and nothing else. These tests pin the two +// ways that silently produces an "unknown signer" revert on chain: a missing +// recovery id, and a high-s signature that OpenZeppelin rejects outright. + +func TestNormalizeRemote_RecoversMissingV(t *testing.T) { + key, _ := crypto.GenerateKey() + local := NewLocalSigner(key) + digest := Digest([]byte("payload"), TagState) + + want, err := local.Sign(digest) + if err != nil { + t.Fatal(err) + } + + // Throw away v, exactly as a KMS backend would. + r := new(big.Int).SetBytes(want[0:32]) + s := new(big.Int).SetBytes(want[32:64]) + + got, err := NormalizeRemote(digest, r, s, local.Address()) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("recovered signature differs\n got %x\nwant %x", got, want) + } + if got[64] != 27 && got[64] != 28 { + t.Fatalf("v must be 27/28 for ECDSA.recover, got %d", got[64]) + } +} + +func TestNormalizeRemote_CanonicalisesHighS(t *testing.T) { + key, _ := crypto.GenerateKey() + local := NewLocalSigner(key) + digest := Digest([]byte("payload"), TagPacket) + + want, err := local.Sign(digest) + if err != nil { + t.Fatal(err) + } + r := new(big.Int).SetBytes(want[0:32]) + s := new(big.Int).SetBytes(want[32:64]) + + // (r, n-s) is an equally valid signature over the same digest and the same + // key -- secp256k1 is malleable. A KMS may return it, and the contract + // reverts on it. Feeding the malleated form in must yield the canonical one. + high := new(big.Int).Sub(secp256k1.S256().Params().N, s) + if high.Cmp(halfN) <= 0 { + t.Skip("generated key produced a signature whose complement is also low-s") + } + + got, err := NormalizeRemote(digest, r, high, local.Address()) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("high-s not canonicalised\n got %x\nwant %x", got, want) + } +} + +func TestNormalizeRemote_RejectsWrongSigner(t *testing.T) { + key, _ := crypto.GenerateKey() + other, _ := crypto.GenerateKey() + local := NewLocalSigner(key) + digest := Digest([]byte("payload"), TagState) + + sig, _ := local.Sign(digest) + r := new(big.Int).SetBytes(sig[0:32]) + s := new(big.Int).SetBytes(sig[32:64]) + + // A backend that signed with a different key must be caught here rather + // than on chain, where it is indistinguishable from a misconfigured + // attestor set. + if _, err := NormalizeRemote(digest, r, s, crypto.PubkeyToAddress(other.PublicKey)); err == nil { + t.Fatal("expected a signature not matching the claimed address to be rejected") + } +} From a389be35ffd1a0dd9ff047eb86f6a3438d3b6644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 17 Aug 2026 11:31:51 +0200 Subject: [PATCH 45/61] feat(qbftproofapi): the guards a stateless attestor cannot provide --- cmd/qbftproofapi/guard.go | 275 +++++++++++++++++++++++++++++++++ cmd/qbftproofapi/guard_test.go | 136 ++++++++++++++++ cmd/qbftproofapi/main.go | 55 +++++-- cmd/qbftproofapi/outbound.go | 241 ++++++++++++++++++----------- 4 files changed, 599 insertions(+), 108 deletions(-) create mode 100644 cmd/qbftproofapi/guard.go create mode 100644 cmd/qbftproofapi/guard_test.go diff --git a/cmd/qbftproofapi/guard.go b/cmd/qbftproofapi/guard.go new file mode 100644 index 00000000..62430b74 --- /dev/null +++ b/cmd/qbftproofapi/guard.go @@ -0,0 +1,275 @@ +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 ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "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. +func openGuardState(dir string) (*guardState, error) { + g := &guardState{seen: map[uint64]uint64{}} + path := filepath.Join(dir, seenFile) + + f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o600) + if err != nil { + return nil, err + } + dec := json.NewDecoder(f) + for { + var r seenRecord + if err := dec.Decode(&r); err != nil { + break + } + g.seen[r.Height] = r.Timestamp + } + f.Close() + + 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 00000000..1cf1d20d --- /dev/null +++ b/cmd/qbftproofapi/guard_test.go @@ -0,0 +1,136 @@ +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" + "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 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/main.go b/cmd/qbftproofapi/main.go index 50c57f9a..9f379d17 100644 --- a/cmd/qbftproofapi/main.go +++ b/cmd/qbftproofapi/main.go @@ -46,12 +46,12 @@ import ( rpchttp "github.com/cometbft/cometbft/rpc/client/http" - 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" + 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/aggregatorpb" + "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" "github.com/peersyst/cbdc-node/x/qbftclient/proofapipb" "github.com/cosmos/cosmos-sdk/codec" @@ -62,7 +62,6 @@ type config struct { besuRPC string cbdcRPC string attestorGRPC string - attestorHTTP 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) @@ -70,6 +69,7 @@ type config struct { 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() { @@ -78,12 +78,7 @@ func main() { 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 AggregatorService") - // Two addresses for one sidecar because absence is deliberately not on its - // gRPC surface: upstream's GetAttestationsRequest cannot distinguish - // "attest this value" from "attest there is no value", so timeouts go - // through the HTTP endpoint where that intent is explicit. - flag.StringVar(&cfg.attestorHTTP, "attestor-http", "http://127.0.0.1:8090", "attestor sidecar HTTP (for /attest/absence)") + 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)") @@ -91,10 +86,15 @@ func main() { 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 == "" { - fmt.Fprintln(os.Stderr, "required: -router -signer") + if routerHex == "" || cfg.signer == "" || cfg.stateDir == "" { + fmt.Fprintln(os.Stderr, "required: -router -signer -state-dir") flag.Usage() os.Exit(2) } @@ -122,7 +122,32 @@ func main() { cfg: cfg, cdc: encCfg.Codec, cbdc: cbdc, - attestor: aggregatorpb.NewAggregatorServiceClient(attConn), + 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) @@ -135,7 +160,8 @@ func main() { 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 (absence via %s)", cfg.cbdcChainID, cfg.besuChainID, cfg.attestorGRPC, cfg.attestorHTTP) + 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) } @@ -145,7 +171,8 @@ type server struct { cfg config cdc codec.Codec cbdc *rpchttp.HTTP - attestor aggregatorpb.AggregatorServiceClient + attestor attestorpb.AttestationServiceClient + guard *guardState } // RelayByTx is the one method cosmos/ibc-relayer invokes. Dispatch is on the diff --git a/cmd/qbftproofapi/outbound.go b/cmd/qbftproofapi/outbound.go index 17bdb6ec..0c5775c1 100644 --- a/cmd/qbftproofapi/outbound.go +++ b/cmd/qbftproofapi/outbound.go @@ -11,12 +11,8 @@ import ( "bytes" "context" "encoding/hex" - "encoding/json" "fmt" - "io" - "net/http" - "strings" - "time" + "log" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" @@ -24,7 +20,7 @@ import ( 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/aggregatorpb" + "github.com/peersyst/cbdc-node/x/qbftclient/attestor/attestorpb" "github.com/peersyst/cbdc-node/x/qbftclient/proofapipb" ) @@ -50,35 +46,22 @@ func (s *server) outbound(ctx context.Context, req *proofapipb.RelayByTxRequest) if err != nil { return nil, fmt.Errorf("cbdc status: %w", err) } - height := uint64(st.SyncInfo.LatestBlockHeight) - - // One attestation covers every packet in the batch: the sidecar reads each - // commitment from its own cbdc-node at `height` and refuses anything it - // cannot verify, so a bogus sequence fails here, not on-chain. - paths := make([][]byte, 0, len(packets)) - for _, pk := range packets { - paths = append(paths, attestor.CommitmentPath(pk.SourceClient, pk.Sequence)) - } - att, err := s.attestor.GetAttestations(ctx, &aggregatorpb.GetAttestationsRequest{ - Height: height, - Packets: paths, - }) + height, err := uint64Height(st.SyncInfo.LatestBlockHeight) if err != nil { - return nil, fmt.Errorf("attestor: %w", err) - } - if att.GetStateAttestation() == nil || att.GetPacketAttestation() == nil { - return nil, fmt.Errorf("attestor returned incomplete attestations for height %d", height) + return nil, err } - // The light client verifies abi.encode(AttestationProof{data, signatures}); - // the aggregator hands back the two halves unwrapped. - stateProof, err := attestor.EncodeProof(att.GetStateAttestation().GetAttestedData(), att.GetStateAttestation().GetSignatures()) + // 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, fmt.Errorf("encode state proof: %w", err) + return nil, err } - packetProof, err := attestor.EncodeProof(att.GetPacketAttestation().GetAttestedData(), att.GetPacketAttestation().GetSignatures()) + packetProof, err := s.attestPackets(ctx, height, packets, attestor.CommitmentKindPacket) if err != nil { - return nil, fmt.Errorf("encode packet proof: %w", err) + return nil, err } calldata, err := multicallRecv(dstClient, stateProof, packets, packetProof, height) @@ -123,32 +106,21 @@ func (s *server) outboundAck(ctx context.Context, req *proofapipb.RelayByTxReque if err != nil { return nil, fmt.Errorf("cbdc status: %w", err) } - height := uint64(st.SyncInfo.LatestBlockHeight) - - paths := make([][]byte, 0, len(packets)) - for _, pk := range packets { - // The ack is keyed by the packet's DESTINATION client — the receiver - // wrote it — the mirror of the commitment keying in outbound recv. - paths = append(paths, attestor.AckPath(pk.DestinationClient, pk.Sequence)) - } - att, err := s.attestor.GetAttestations(ctx, &aggregatorpb.GetAttestationsRequest{ - Height: height, - Packets: paths, - }) + height, err := uint64Height(st.SyncInfo.LatestBlockHeight) if err != nil { - return nil, fmt.Errorf("attestor: %w", err) - } - if att.GetStateAttestation() == nil || att.GetPacketAttestation() == nil { - return nil, fmt.Errorf("attestor returned incomplete attestations for height %d", height) + return nil, err } - stateProof, err := attestor.EncodeProof(att.GetStateAttestation().GetAttestedData(), att.GetStateAttestation().GetSignatures()) + // 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, fmt.Errorf("encode state proof: %w", err) + return nil, err } - packetProof, err := attestor.EncodeProof(att.GetPacketAttestation().GetAttestedData(), att.GetPacketAttestation().GetSignatures()) + packetProof, err := s.attestPackets(ctx, height, packets, attestor.CommitmentKindAck) if err != nil { - return nil, fmt.Errorf("encode packet proof: %w", err) + return nil, err } calldata, err := multicallAck(dstClient, stateProof, packets, acks, packetProof, height) @@ -188,42 +160,34 @@ func (s *server) outboundTimeout(ctx context.Context, req *proofapipb.RelayByTxR if len(packets) == 0 { return nil, fmt.Errorf("no SendPacket events for client %s in the given transactions", dstClient) } - // The sidecar signs receipt paths keyed by ITS configured cbdc-node client - // id; a packet destined elsewhere would get 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. - seqs := make([]uint64, 0, len(packets)) + // 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) } - seqs = append(seqs, pk.Sequence) } st, err := s.cbdc.Status(ctx) if err != nil { return nil, fmt.Errorf("cbdc status: %w", err) } - height := uint64(st.SyncInfo.LatestBlockHeight) - - // The state attestation comes from the same gRPC surface as ever — with no - // packets requested, so only the updateClient half is signed. The absence - // half CANNOT come from there: upstream's request has no way to say - // "attest there is no value", so the sidecar keeps non-membership behind - // its explicit-intent HTTP endpoint (see cmd/qbftattestor/grpc.go). - att, err := s.attestor.GetAttestations(ctx, &aggregatorpb.GetAttestationsRequest{Height: height}) + height, err := uint64Height(st.SyncInfo.LatestBlockHeight) if err != nil { - return nil, fmt.Errorf("attestor: %w", err) - } - if att.GetStateAttestation() == nil { - return nil, fmt.Errorf("attestor returned no state attestation for height %d", height) + return nil, err } - stateProof, err := attestor.EncodeProof(att.GetStateAttestation().GetAttestedData(), att.GetStateAttestation().GetSignatures()) + + // 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, fmt.Errorf("encode state proof: %w", err) + return nil, err } - - absenceProof, err := s.attestAbsence(ctx, height, seqs) + absenceProof, err := s.attestPackets(ctx, height, packets, attestor.CommitmentKindReceipt) if err != nil { return nil, err } @@ -235,45 +199,134 @@ func (s *server) outboundTimeout(ctx context.Context, req *proofapipb.RelayByTxR return &proofapipb.RelayByTxResponse{Tx: calldata, Address: s.cfg.router.Hex()}, nil } -// attestAbsence asks the sidecar's HTTP surface to sign that no receipt exists -// for the given sequences at height. The response's proof is already the full -// abi.encode(AttestationProof) blob — the sidecar wraps absence proofs itself, -// unlike the gRPC path which returns the halves for us to encode. -func (s *server) attestAbsence(ctx context.Context, height uint64, seqs []uint64) ([]byte, error) { - body, err := json.Marshal(map[string]any{"height": height, "sequences": seqs}) +// 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 } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.attestorHTTP+"/attest/absence", bytes.NewReader(body)) + + 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 } - resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(httpReq) + 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("attestor absence: %w", err) + return nil, fmt.Errorf("encode state proof: %w", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - // The refusal body says WHY the attestor declined — a value exists, the - // height is unproven, etc. — which is the difference between a - // diagnosable corridor and a silent retry loop. Capped so a misbehaving - // endpoint cannot stream unboundedly. - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return nil, fmt.Errorf("attestor refused absence (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + 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) } - var out struct { - Proof string `json:"proof"` + + 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) } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, fmt.Errorf("attestor absence response: %w", 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 := hex.DecodeString(strings.TrimPrefix(out.Proof, "0x")) + proof, err := attestor.EncodeProof(att.GetAttestedData(), [][]byte{att.GetSignature()}) if err != nil { - return nil, fmt.Errorf("attestor absence proof hex: %w", err) + 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) { From bf7a3d0e1f5ce2c8efbc32bf167537f9a90715e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 17 Aug 2026 11:32:06 +0200 Subject: [PATCH 46/61] feat(qbftaggregator): m-of-n fan-out that refuses divergence --- cmd/qbftaggregator/main.go | 216 ++++++++++++++++++++++++++++++++ cmd/qbftaggregator/main_test.go | 110 ++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 cmd/qbftaggregator/main.go create mode 100644 cmd/qbftaggregator/main_test.go diff --git a/cmd/qbftaggregator/main.go b/cmd/qbftaggregator/main.go new file mode 100644 index 00000000..c565788b --- /dev/null +++ b/cmd/qbftaggregator/main.go @@ -0,0 +1,216 @@ +// 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 := strings.Split(*attestors, ",") + 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 { + addr = strings.TrimSpace(addr) + 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), *attestors) + log.Fatal(srv.Serve(lis)) +} + +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 + var sigs [][]byte + 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) + } + sigs = append(sigs, att.GetSignatures()...) + } + + if len(sigs) < a.minSigs { + return nil, fmt.Errorf( + "%s attestation has %d signature(s), need %d: %s", + kind, len(sigs), a.minSigs, strings.Join(refused, "; ")) + } + if len(refused) > 0 { + log.Printf("%s height=%d: proceeding with %d/%d signatures; 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 00000000..0dff2b78 --- /dev/null +++ b/cmd/qbftaggregator/main_test.go @@ -0,0 +1,110 @@ +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") + } +} From 14a8161fa3e45255aa09de0af982a25e92fc6eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 17 Aug 2026 11:32:26 +0200 Subject: [PATCH 47/61] feat(corridor): run upstream cosmos/ibc-attestor, and the tooling the swap needs --- .gitignore | 17 ++ cmd/attestcheck/main.go | 132 +++++++++++++++ scripts/corridor/attestor-upstream.scenb.toml | 45 ++++++ scripts/corridor/check-corridor.sh | 151 +++++++++++++++++ scripts/corridor/corridor-env.sh | 153 ++++++++++++++++++ scripts/corridor/keystore-import/main.go | 133 +++++++++++++++ scripts/corridor/router-from-broadcast.sh | 118 ++++++++++++++ scripts/corridor/up-corridor.sh | 80 +++++++-- 8 files changed, 813 insertions(+), 16 deletions(-) create mode 100644 cmd/attestcheck/main.go create mode 100644 scripts/corridor/attestor-upstream.scenb.toml create mode 100755 scripts/corridor/check-corridor.sh create mode 100755 scripts/corridor/corridor-env.sh create mode 100644 scripts/corridor/keystore-import/main.go create mode 100755 scripts/corridor/router-from-broadcast.sh diff --git a/.gitignore b/.gitignore index a1141193..4c080016 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,13 @@ release/ *.html 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 @@ -27,3 +34,13 @@ bin/ # 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* + diff --git a/cmd/attestcheck/main.go b/cmd/attestcheck/main.go new file mode 100644 index 00000000..64ffd21c --- /dev/null +++ b/cmd/attestcheck/main.go @@ -0,0 +1,132 @@ +// 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) + } + height := tip - 5 + if height < 2 { + log.Fatalf("chain is only %d blocks tall; nothing safe to attest yet", tip) + } + + 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/scripts/corridor/attestor-upstream.scenb.toml b/scripts/corridor/attestor-upstream.scenb.toml new file mode 100644 index 00000000..ee051850 --- /dev/null +++ b/scripts/corridor/attestor-upstream.scenb.toml @@ -0,0 +1,45 @@ +# 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: +# ibc_attestor server --config scripts/corridor/attestor-upstream.scenb.toml \ +# --chain-type cosmos --signer-type local \ +# --keystore-password "$(cat scripts/corridor/attestor-keystore.scenb.pass)" +# +# 🔴 THE KEYSTORE MUST RECOVER 0x256f6899dD7d9b62769Ec802F4eb5aaC05296018. +# 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 = "scripts/corridor/attestor-keystore.scenb" diff --git a/scripts/corridor/check-corridor.sh b/scripts/corridor/check-corridor.sh new file mode 100755 index 00000000..57514f7c --- /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}" +CBDCD="${CBDCD:-bin/cbdcd}" +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=$("$CBDCD" 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=$("$CBDCD" 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 00000000..cd7c2e04 --- /dev/null +++ b/scripts/corridor/corridor-env.sh @@ -0,0 +1,153 @@ +#!/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 ───────────────────── +CBDC_CHAIN="${CBDC_CHAIN:-cbdc-honduras_5040000-1}" # chain identity +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/.cbdcd-honduras}" +CBDCD="${CBDCD:-$CORRIDOR_HOME/bin/cbdcd}" + +# ── 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=$("$CBDCD" keys show "$RELAYER_KEY_NAME" -a \ + --keyring-backend test --home "$CBDC_HOME" 2>/dev/null || true) + +KEYSTORE="${KEYSTORE:-$CORRIDOR_HOME/scripts/corridor/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 CBDCD 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 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" +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 00000000..48bfad0e --- /dev/null +++ b/scripts/corridor/keystore-import/main.go @@ -0,0 +1,133 @@ +// 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 scripts/corridor/attestor-key.scenb.json \ +// -out scripts/corridor/attestor-keystore.scenb \ +// -password-file scripts/corridor/attestor-keystore.scenb.pass +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/router-from-broadcast.sh b/scripts/corridor/router-from-broadcast.sh new file mode 100755 index 00000000..fdeda30f --- /dev/null +++ b/scripts/corridor/router-from-broadcast.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Recovers the ICS26Router address from a `forge script` broadcast file, so the +# last value in the deployment does not have to move by hand. +# +# WHY IT PROBES INSTEAD OF PARSING BY NAME +# +# The obvious approach -- pick the entry whose contractName is "ICS26Router" -- +# gets the WRONG address. That entry is the implementation; the router the +# corridor uses is the ERC1967Proxy in front of it. And DeployCorridorHub +# deploys THREE proxies (router, transfer app, GMP), all recorded under the same +# contractName, so nothing in the file distinguishes them. +# +# Position would distinguish them, but only until someone reorders the script. +# So each candidate is asked a question only the router can answer: +# +# getIBCApp("transfer") -> non-zero AND getClient() -> non-zero +# +# That is positive identification against the chain, not inference from a file, +# and it is the same discipline the runbook already applies to reading contract +# state back rather than trusting the deploy log. +# +# Usage: +# scripts/corridor/router-from-broadcast.sh [-q] +# -q print only the address (for command substitution) +# +# Honours BESU_RPC, BESU_CHAIN, BESU_CLIENT and EUREKA_DIR from corridor-env.sh. +set -uo pipefail + +QUIET=0 +[ "${1:-}" = "-q" ] && QUIET=1 +say() { [ "$QUIET" = 1 ] || echo "$@" >&2; } +die() { echo "router-from-broadcast: $*" >&2; exit 1; } + +BESU_RPC="${BESU_RPC:-http://127.0.0.1:8845}" +BESU_CLIENT="${BESU_CLIENT:-client-0}" +SCRIPT_NAME="${SCRIPT_NAME:-DeployCorridorHub.s.sol}" + +command -v cast >/dev/null 2>&1 || die "cast (foundry) is required" +command -v jq >/dev/null 2>&1 || die "jq is required" + +# Chain id from the node rather than from configuration: the broadcast files are +# filed under the chain they were sent to, so asking the node is what guarantees +# we read the directory that matches the RPC we are about to probe. +BESU_CHAIN="${BESU_CHAIN:-}" +if [ -z "$BESU_CHAIN" ]; then + hex=$(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') + [ -n "$hex" ] || die "cannot reach $BESU_RPC to determine the chain id" + BESU_CHAIN=$((16#$hex)) +fi + +# ── Find the broadcast file ────────────────────────────────────────────────── +# EUREKA_DIR is the writable checkout the script was run from. Searching a few +# likely roots beats requiring it, because that directory is a build artefact +# people place wherever there was room. +# +# 🔴 ALL matching files are read, not the first one found. Taking the first is +# how this silently pointed a live corridor at a second, unrelated deployment: +# re-running the deploy from a different checkout leaves two broadcast files, +# both valid, and "first found" depends on directory order. Two deployments is +# an ambiguity to report, never one to resolve by luck. +BROADCASTS="" +if [ -n "${BROADCAST:-}" ]; then + BROADCASTS="$BROADCAST" +else + for root in "${EUREKA_DIR:-}" "$PWD" "$PWD/.." "$HOME"; do + [ -n "$root" ] && [ -d "$root" ] || continue + found=$(find "$root" -maxdepth 6 -path "*/broadcast/$SCRIPT_NAME/$BESU_CHAIN/run-latest.json" \ + -type f 2>/dev/null) + BROADCASTS="$BROADCASTS $found" + done + # Same file reachable from several roots is not two deployments. + BROADCASTS=$(printf '%s\n' $BROADCASTS | sed '/^$/d' | xargs -r -n1 readlink -f 2>/dev/null | sort -u) +fi +[ -n "$BROADCASTS" ] \ + || die "no broadcast file found for $SCRIPT_NAME on chain $BESU_CHAIN. + Set BROADCAST=/path/to/run-latest.json, or EUREKA_DIR to the checkout you ran + forge from. Without it, set ROUTER by hand from the deploy log." + +# ── Candidates: every contract every matching run created ─────────────────── +candidates="" +for b in $BROADCASTS; do + [ -r "$b" ] || continue + say "broadcast $b" + candidates="$candidates $(jq -r '[.transactions[]? | select(.transactionType=="CREATE") + | .contractAddress // empty] | unique[]' "$b" 2>/dev/null)" +done +candidates=$(printf '%s\n' $candidates | sed '/^$/d' | sort -u) +[ -n "$candidates" ] || die "no CREATE transactions in: $BROADCASTS" + +# ── Identify by behaviour ──────────────────────────────────────────────────── +zero=0x0000000000000000000000000000000000000000 +matches="" +for addr in $candidates; do + app=$(cast call "$addr" "getIBCApp(string)(address)" transfer --rpc-url "$BESU_RPC" 2>/dev/null | tr -d '[:space:]') + [ -n "$app" ] && [ "$app" != "$zero" ] || continue + cli=$(cast call "$addr" "getClient(string)(address)" "$BESU_CLIENT" --rpc-url "$BESU_RPC" 2>/dev/null | tr -d '[:space:]') + [ -n "$cli" ] && [ "$cli" != "$zero" ] || continue + say " candidate $addr -> transfer $app, client $BESU_CLIENT $cli" + matches="$matches $addr" +done + +set -- $matches +case $# in + 0) die "no address in the broadcast answers as an ICS26Router with an app at + port 'transfer' and a client '$BESU_CLIENT' on $BESU_RPC. + Either the deploy did not complete, or the chain was re-genesised since -- + Besu regenerates genesis on every startBesu.sh, taking the contracts with it." ;; + 1) ;; + *) die "$# addresses answer as ICS26Routers on chain $BESU_CHAIN:$matches + More than one corridor is deployed. Set ROUTER explicitly to the one you mean + -- guessing here would point the proof api and relayer at the wrong contract + set, and they would fail in ways that look like a broken corridor rather than + a misconfigured one." ;; +esac + +[ "$QUIET" = 1 ] && echo "$1" || echo "ROUTER=$1" diff --git a/scripts/corridor/up-corridor.sh b/scripts/corridor/up-corridor.sh index b520e9d7..1f3819e0 100755 --- a/scripts/corridor/up-corridor.sh +++ b/scripts/corridor/up-corridor.sh @@ -27,12 +27,26 @@ ROUTER="${ROUTER:?ICS26Router address required}" LIGHT_CLIENT="${LIGHT_CLIENT:?AttestationLightClient address required}" CBDC_CLIENT="${CBDC_CLIENT:?cbdc-node client id required}" BESU_CLIENT="${BESU_CLIENT:?Besu client id required}" -ATTESTOR_KEY="${ATTESTOR_KEY:?attestor secp256k1 key required, hex without 0x}" +# The signer is cosmos/ibc-attestor (DEC-32), which reads an encrypted keystore +# rather than a hex key. ATTESTOR_KEY is no longer used to launch anything; it +# remains accepted only so an operator who still exports it gets the identity +# comparison below instead of a silent skip. +ATTESTOR_KEY="${ATTESTOR_KEY:-}" +IBC_ATTESTOR_BIN="${IBC_ATTESTOR_BIN:?path to the cosmos/ibc-attestor binary required}" +ATTESTOR_CONFIG="${ATTESTOR_CONFIG:?ibc-attestor TOML config required}" +KEYSTORE_PASSWORD_FILE="${KEYSTORE_PASSWORD_FILE:?file holding the keystore password required}" +# 🔴 The address baked into AttestationLightClient's constructor. The set has no +# setter, so a mismatch costs a client redeploy plus a migrateClient -- which is +# exactly why this is checked at bring-up rather than discovered at relay time. +ATTESTOR_ADDR_EXPECTED="${ATTESTOR_ADDR_EXPECTED:?expected attestor address required}" +# The attestor's own AttestationService; qbftproofapi dials this directly. +ATTESTOR_GRPC="${ATTESTOR_GRPC:-127.0.0.1:8093}" RELAYER_KEY_NAME="${RELAYER_KEY_NAME:?cbdc-node keyring name of the relayer for THIS leg}" STATE_ROOT="${STATE_ROOT:-$PWD/.corridor}" +# Retained only for the per-leg port-collision story; upstream serves no HTTP +# API beyond /healthz, and absence attestation now travels the gRPC path. ATTESTOR_HTTP="${ATTESTOR_HTTP:-127.0.0.1:8090}" -ATTESTOR_GRPC="${ATTESTOR_GRPC:-127.0.0.1:8091}" PROOF_API="${PROOF_API:-127.0.0.1:8888}" LEG="$BESU_CHAIN-$BESU_CLIENT" @@ -53,7 +67,11 @@ ok() { echo " ok $*"; } # # The attestor's own freeze guard cannot save you here: it is a durable log under # -state-dir, so two processes sharing a key have no shared guard at all. -KEY_FP=$(printf '%s' "$ATTESTOR_KEY" | sha256sum | cut -c1-16) +# Keyed by the attestor ADDRESS, not the key bytes. Under a keystore or a +# remote signer there is no hex key here to hash, and the address is the thing +# the light client's immutable set actually contains -- so it is both available +# in every custody model and the more meaningful identifier. +KEY_FP=$(printf '%s' "${ATTESTOR_ADDR_EXPECTED:-$ATTESTOR_KEY}" | sha256sum | cut -c1-16) # Decided on the field, not on a pipeline's exit status: `grep ... | head` reports # HEAD's status, which is 0 even when grep matched nothing, so an empty registry # would take the branch and block every first deployment. Whether that construct @@ -125,39 +143,65 @@ fi # ── Processes ───────────────────────────────────────────────────────────────── mkdir -p "$LEG_DIR/attestor-state" -nohup bin/qbftattestor \ - -cbdc-rpc "$CBDC_RPC" -listen "$ATTESTOR_HTTP" -grpc "$ATTESTOR_GRPC" \ - -client-id "$CBDC_CLIENT" -key "$ATTESTOR_KEY" -cbdc-chain-id "$CBDC_CHAIN" \ - -light-client "$LIGHT_CLIENT" -besu-chain-id "$BESU_CHAIN" \ - -state-dir "$LEG_DIR/attestor-state" > "$LEG_DIR/attestor.log" 2>&1 & -sleep 4 +# DEC-32: the signer is cosmos/ibc-attestor, not cmd/qbftattestor. Two processes +# rather than one, because upstream is STATELESS -- it keeps no record of what it +# has signed and cannot detect a re-genesis, and re-signing one height with a +# different timestamp freezes the light client permanently with no unfreeze. +# cmd/qbftproofapi restores those guards without forking upstream. +# +# The two guards upstream lacks -- the durable (height,timestamp) log against a +# permanent freeze, and proof-backed absence against a wrongful refund -- live +# inside cmd/qbftproofapi, which is the sole caller of the attestor. +# +# qbftproofapi (guards) -> ibc_attestor ($ATTESTOR_GRPC) +# +# ⚠️ If a second caller ever reaches the attestor (upstream's cosmos-to-eth +# aggregator at m-of-n is the realistic case) those guards no longer cover it and +# must move back IN FRONT of the attestor rather than beside it. +nohup "$IBC_ATTESTOR_BIN" server \ + --config "$ATTESTOR_CONFIG" --chain-type cosmos --signer-type local \ + --keystore-password "$(cat "$KEYSTORE_PASSWORD_FILE")" \ + > "$LEG_DIR/attestor.log" 2>&1 & +sleep 6 +sleep 2 # Probe the socket, do not trust the log. The attestor prints "listening on" # BEFORE the bind can fail, so a log grep happily reports success while the # process is dying on an address already in use -- which is precisely how a # second leg silently attaches itself to the first leg's attestor. -ATTESTOR_ADDR=$(curl -s -m 3 "http://$ATTESTOR_HTTP/address" | grep -oE '0x[a-fA-F0-9]{40}' | head -1) +# Upstream serves no /address, so identity is established the way that +# actually matters: ask for a real attestation and recover the signer from the +# signature. That proves the key in use, not merely what a process claims. +ATTESTOR_ADDR=$(bin/attestcheck -grpc "$ATTESTOR_GRPC" -cbdc-rpc "$CBDC_RPC" 2>/dev/null | grep -oE '0x[a-fA-F0-9]{40}' | head -1) [ -n "$ATTESTOR_ADDR" ] \ - || { tail -5 "$LEG_DIR/attestor.log"; die "attestor is not answering on $ATTESTOR_HTTP"; } + || { tail -5 "$LEG_DIR/attestor.log"; die "attestor is not answering on $ATTESTOR_GRPC"; } # Liveness is not enough: something answering this port proves only that SOME # attestor is there. If the bind failed because another leg already holds the # port, the probe succeeds against THAT leg -- and this corridor would then relay # through an attestor keyed for a different light client. Check identity. -EXPECT_ADDR=$(cast wallet address --private-key "0x$ATTESTOR_KEY" 2>/dev/null) +EXPECT_ADDR="${ATTESTOR_ADDR_EXPECTED:-$(cast wallet address --private-key "0x$ATTESTOR_KEY" 2>/dev/null)}" if [ -n "$EXPECT_ADDR" ] && [ "${ATTESTOR_ADDR,,}" != "${EXPECT_ADDR,,}" ]; then tail -5 "$LEG_DIR/attestor.log" - die "$ATTESTOR_HTTP is answering as $ATTESTOR_ADDR, but this leg's key is $EXPECT_ADDR. - Another leg already holds that port. Give this leg its own ATTESTOR_HTTP / - ATTESTOR_GRPC / PROOF_API, or it will relay through the wrong attestor." + die "$ATTESTOR_GRPC is answering as $ATTESTOR_ADDR, but this leg expects $EXPECT_ADDR. + Another leg already holds that port. Give this leg its own ATTESTOR_GRPC / + PROOF_API, or it will relay through the wrong attestor." fi ok "attestor up, signing as $ATTESTOR_ADDR" +# 🔴 -state-dir keeps the name "attestor-state" even though this is now the PROOF +# API's guard state, not the attestor's. Do NOT rename it to something tidier: the +# directory holds the durable (height,timestamp) log, and pointing a live leg at a +# fresh empty one throws away every height already attested. The guard would then +# permit re-signing a height the light client already holds a different timestamp +# for, which is the permanent freeze it exists to prevent. A misleading name is +# cheap; re-deriving that log is impossible. nohup bin/qbftproofapi \ -listen "$PROOF_API" -besu-rpc "$BESU_RPC" -cbdc-rpc "$CBDC_RPC" \ - -attestor-grpc "$ATTESTOR_GRPC" -attestor-http "http://$ATTESTOR_HTTP" \ + -attestor-grpc "$ATTESTOR_GRPC" \ -router "$ROUTER" -cbdc-chain-id "$CBDC_CHAIN" -besu-chain-id "$BESU_CHAIN" \ -cbdc-client "$CBDC_CLIENT" -besu-client "$BESU_CLIENT" \ -evm-chain-id "${EVM_CHAIN_ID:-1449999}" -signer "$RELAYER_ADDR" \ + -state-dir "$LEG_DIR/attestor-state" \ > "$LEG_DIR/proofapi.log" 2>&1 & sleep 4 # Same reasoning: probe, do not grep. A bind failure here is worse than obvious, @@ -181,6 +225,10 @@ corridor leg up: $CBDC_CHAIN <-> $BESU_CHAIN Relaying is NOT running. Choose one: cosmos/ibc-relayer the intended component. Needs a hash fed to Relay(tx_hash, chain_id) -- it has no event loop by design. + Start it as: TZ=UTC bin/relayer --config + TZ=UTC is mandatory, not tidiness: deadlines are stored as + local wall-clock in a timestamp-without-time-zone column, + so a non-UTC host skews every timeout by its offset. relay-watcher.sh stopgap that watches both chains. Drives recv+ack only; it does NOT build timeouts, so a packet nobody can deliver leaves escrow open until someone drives the timeout by hand. From c60f0bf79164bbc17b1df5ed599c47adf0406a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 17 Aug 2026 11:32:44 +0200 Subject: [PATCH 48/61] fix(corridor): a build target that exists, and docs that name the live attestor --- Makefile | 13 +++++++++++++ scripts/corridor/relay-watcher.sh | 8 ++++++-- scripts/corridor/relayer-config.scenb.yml | 17 +++++++++++++++-- scripts/corridor/relayer-config.yml | 19 +++++++++++++++++-- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 732b98e1..eacc0ea7 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,19 @@ install: go.sum build: go build $(BUILD_FLAGS) -o ./bin/cbdcd ./cmd/cbdcd +# 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" \ diff --git a/scripts/corridor/relay-watcher.sh b/scripts/corridor/relay-watcher.sh index cc967c1e..44b05895 100755 --- a/scripts/corridor/relay-watcher.sh +++ b/scripts/corridor/relay-watcher.sh @@ -8,8 +8,12 @@ # # 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 qbftattestor, which -# signs only what it has independently verified. +# 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 diff --git a/scripts/corridor/relayer-config.scenb.yml b/scripts/corridor/relayer-config.scenb.yml index 2631f766..566d51af 100644 --- a/scripts/corridor/relayer-config.scenb.yml +++ b/scripts/corridor/relayer-config.scenb.yml @@ -9,8 +9,21 @@ # than loudly. # # Run: -# bin/relayer --config scripts/corridor/relayer-config.scenb.yml -# with qbftattestor and qbftproofapi already up. +# TZ=UTC bin/relayer --config scripts/corridor/relayer-config.scenb.yml +# 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' diff --git a/scripts/corridor/relayer-config.yml b/scripts/corridor/relayer-config.yml index a67527ed..506d3109 100644 --- a/scripts/corridor/relayer-config.yml +++ b/scripts/corridor/relayer-config.yml @@ -5,9 +5,24 @@ # 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: -# bin/relayer --config scripts/corridor/relayer-config.yml -# with qbftattestor and qbftproofapi already up. +# TZ=UTC bin/relayer --config scripts/corridor/relayer-config.yml +# 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' From b70b5a75940b025783417bdf2246bb919b2e2afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 17 Aug 2026 11:33:25 +0200 Subject: [PATCH 49/61] chore: satisfy the linter across the branch --- .golangci.yml | 8 +++++++ app/ibc/corridorpause/middleware.go | 4 ++-- cmd/corridord/chains.go | 6 ++++-- cmd/corridord/main.go | 14 ++++++------- cmd/packetconv/main.go | 1 + cmd/qbftattestor/cbdc.go | 10 ++++++++- cmd/qbftattestor/state.go | 2 +- cmd/qbftattestor/state_test.go | 4 ++-- cmd/qbftinit/main.go | 6 ++++++ cmd/qbftproofapi/inbound.go | 6 +++--- cmd/sp1fixture/main.go | 8 ++++--- cmd/v2relay/main.go | 4 ++-- tests/integration/qbft_live_test.go | 11 ++++++++-- tests/integration/qbft_pilot_test.go | 1 + tests/integration/qbftclient_test.go | 8 ++++--- x/cbdc/keeper/msg_server.go | 2 +- x/qbftclient/attestor/aggregatorpb/service.go | 8 +++++++ x/qbftclient/light_client_module.go | 2 +- x/qbftclient/proofapipb/service.go | 8 +++++++ x/qbftclient/prover/besu/client.go | 2 +- x/qbftclient/prover/besu/live_storage_test.go | 8 ++++++- x/qbftclient/prover/besu/live_test.go | 2 ++ x/qbftclient/prover/cosmos/prover.go | 4 ++-- x/qbftclient/prover/cosmos/prover_test.go | 21 ++++++++++++------- x/qbftclient/prover/msgs/msgs.go | 2 +- x/qbftclient/prover/prover_test.go | 1 + x/qbftclient/types/client_message.go | 6 +++--- x/qbftclient/types/extradata.go | 2 +- x/qbftclient/types/state.go | 2 +- x/qbftclient/types/update.go | 14 ++++++------- x/qbftclient/types/update_test.go | 3 ++- x/qbftclient/types/verify.go | 2 +- x/qbftclient/types/verify_test.go | 2 +- 33 files changed, 127 insertions(+), 57 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 8e63ebc2..0788332b 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/app/ibc/corridorpause/middleware.go b/app/ibc/corridorpause/middleware.go index 75e555c9..80b61b4b 100644 --- a/app/ibc/corridorpause/middleware.go +++ b/app/ibc/corridorpause/middleware.go @@ -11,7 +11,7 @@ // 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 -// misbehaviour evidence; and disabling bank sends for the denom also stops every +// 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 @@ -50,7 +50,7 @@ type IBCMiddleware struct { params ParamsGetter } -// NewIBCMiddleware wraps app so both packet directions honour the pause. +// NewIBCMiddleware wraps app so both packet directions honor the pause. func NewIBCMiddleware(params ParamsGetter, app api.IBCModule) IBCMiddleware { return IBCMiddleware{app: app, params: params} } diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index be8d3dc1..cf4ec611 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -573,12 +573,14 @@ func (d *driver) deliverToCbdc(ctx context.Context, seq uint64, pktHex string, e } defer os.RemoveAll(dir) unsigned := filepath.Join(dir, fmt.Sprintf("msg-%d.json", seq)) - args := []string{"run", "./cmd/qbftrelay", + 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} + "-signer", d.cfg.signer, "-out", unsigned, + } args = append(args, extra...) if _, err := run(ctx, "go", args...); err != nil { return fmt.Errorf("qbftrelay: %w", err) diff --git a/cmd/corridord/main.go b/cmd/corridord/main.go index 17a89bca..dd63477a 100644 --- a/cmd/corridord/main.go +++ b/cmd/corridord/main.go @@ -1,6 +1,6 @@ // Command corridord drives the cbdc-node <-> Besu corridor automatically. // -// SCOPE, AND WHAT THIS IS NOT +// # 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 @@ -12,7 +12,7 @@ // 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 +// # 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 @@ -107,9 +107,7 @@ func main() { log.Print("shutting down") return case <-t.C: - if err := d.tick(ctx); err != nil { - log.Printf("tick: %v", err) - } + d.tick(ctx) } } } @@ -126,7 +124,10 @@ type driver struct { attested map[uint64]bool // heights already pushed to the light client } -func (d *driver) tick(ctx context.Context) error { +// 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) } @@ -142,7 +143,6 @@ func (d *driver) tick(ctx context.Context) error { if err := d.ackInbound(ctx); err != nil { log.Printf("ack-inbound: %v", err) } - return nil } // outbound moves cbdc-node -> Besu. There is no proof to fetch: the sidecar diff --git a/cmd/packetconv/main.go b/cmd/packetconv/main.go index babf29b3..bdb3d8b9 100644 --- a/cmd/packetconv/main.go +++ b/cmd/packetconv/main.go @@ -121,6 +121,7 @@ func main() { } 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) } diff --git a/cmd/qbftattestor/cbdc.go b/cmd/qbftattestor/cbdc.go index c110e67a..bbbc5b81 100644 --- a/cmd/qbftattestor/cbdc.go +++ b/cmd/qbftattestor/cbdc.go @@ -60,7 +60,15 @@ func (c *cbdcClient) blockTimeSeconds(ctx context.Context, height uint64) (uint6 if err != nil { return 0, fmt.Errorf("parse block time: %w", err) } - return uint64(t.Unix()), nil + // 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. diff --git a/cmd/qbftattestor/state.go b/cmd/qbftattestor/state.go index 4af3b58c..2d7942d9 100644 --- a/cmd/qbftattestor/state.go +++ b/cmd/qbftattestor/state.go @@ -154,7 +154,7 @@ func (s *server) openState(dir string) error { // 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 amortises across thousands of attestations +// 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 { diff --git a/cmd/qbftattestor/state_test.go b/cmd/qbftattestor/state_test.go index 8f68b8a6..5699966a 100644 --- a/cmd/qbftattestor/state_test.go +++ b/cmd/qbftattestor/state_test.go @@ -8,9 +8,9 @@ import ( "github.com/stretchr/testify/require" ) -func newTestServer(t *testing.T, dir string, cap int) *server { +func newTestServer(t *testing.T, dir string, capacity int) *server { t.Helper() - s := &server{seen: map[uint64]uint64{}, seenCap: cap} + s := &server{seen: map[uint64]uint64{}, seenCap: capacity} require.NoError(t, s.openState(dir)) t.Cleanup(func() { s.seenLog.Close() }) return s diff --git a/cmd/qbftinit/main.go b/cmd/qbftinit/main.go index 55b203ee..b124c3e8 100644 --- a/cmd/qbftinit/main.go +++ b/cmd/qbftinit/main.go @@ -118,6 +118,11 @@ func run(ctx context.Context, p params) error { 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), @@ -152,6 +157,7 @@ func run(ctx context.Context, p params) error { // 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) diff --git a/cmd/qbftproofapi/inbound.go b/cmd/qbftproofapi/inbound.go index 6d11002b..f90751f4 100644 --- a/cmd/qbftproofapi/inbound.go +++ b/cmd/qbftproofapi/inbound.go @@ -279,12 +279,12 @@ func (s *server) clientLatestHeight(ctx context.Context, clientID string) (uint6 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 any codectypes.Any - if err := any.Unmarshal(res.Response.Value); err != nil { + 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(any.Value); err != nil { + 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/sp1fixture/main.go b/cmd/sp1fixture/main.go index 7da55356..85da781f 100644 --- a/cmd/sp1fixture/main.go +++ b/cmd/sp1fixture/main.go @@ -97,8 +97,9 @@ func run(rpcAddr string, trustedH, targetH int64, outPath string) error { } header := &ibctm.Header{ - SignedHeader: targetCommit.SignedHeader.ToProto(), - ValidatorSet: targetValsProto, + SignedHeader: targetCommit.SignedHeader.ToProto(), + ValidatorSet: targetValsProto, + //nolint:gosec // height read from a live node moments earlier TrustedHeight: clienttypes.NewHeight(revision, uint64(trustedH)), TrustedValidators: trustedValsProto, } @@ -111,6 +112,7 @@ func run(rpcAddr string, trustedH, targetH int64, outPath string) error { 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"}, @@ -171,7 +173,7 @@ func run(rpcAddr string, trustedH, targetH int64, outPath string) error { if err != nil { return err } - if err := os.WriteFile(outPath, bz, 0o644); err != nil { + if err := os.WriteFile(outPath, bz, 0o600); err != nil { return err } fmt.Printf("wrote %s\n", outPath) diff --git a/cmd/v2relay/main.go b/cmd/v2relay/main.go index eab42728..5f70a2f4 100644 --- a/cmd/v2relay/main.go +++ b/cmd/v2relay/main.go @@ -14,8 +14,8 @@ // 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 licence bar -// (a commercial licence was adopted) and its inability to sign eth_secp256k1 +// 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 diff --git a/tests/integration/qbft_live_test.go b/tests/integration/qbft_live_test.go index c36e6382..fa82254b 100644 --- a/tests/integration/qbft_live_test.go +++ b/tests/integration/qbft_live_test.go @@ -28,7 +28,7 @@ import ( qbfttypes "github.com/peersyst/cbdc-node/x/qbftclient/types" ) -// storageWriterCode is a contract whose entire behaviour is +// storageWriterCode is a contract whose entire behavior is // `sstore(calldata[0:32], calldata[32:64])` — enough to place a real packet // commitment at the slot solidity-ibc-eureka would use, without needing Foundry to // build the real contracts. @@ -39,6 +39,8 @@ const storageWriterCode = "0x6008600c60003960086000f36020356000355500" // hardcoded value restricts this test to whichever spoke it was written against: // signing fails with "Wrong chainId", and the ClientState below would describe // the wrong counterparty. +// +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func liveChainID(t *testing.T, ctx context.Context, c *rpc.Client) *big.Int { t.Helper() @@ -90,7 +92,8 @@ func TestLive_InboundLegEndToEnd(t *testing.T) { Sequence: 1, SourceClient: "qbft-0", DestinationClient: "07-tendermint-0", - TimeoutTimestamp: uint64(time.Now().Add(time.Hour).Unix()), + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap + TimeoutTimestamp: uint64(time.Now().Add(time.Hour).Unix()), Payloads: []channeltypesv2.Payload{{ SourcePort: "transfer", DestinationPort: "transfer", @@ -129,6 +132,7 @@ func TestLive_InboundLegEndToEnd(t *testing.T) { // Besu's wall clock are unrelated, and what is under test here is byte // agreement, not time policy — which VerifyHeader's own unit tests cover. clientStateBz, err := cdc.Marshal(&qbfttypes.ClientState{ + //nolint:gosec // a Besu chain id is small and positive ChainId: uint64(liveChainID(t, ctx, rpcClient).Int64()), TrustingPeriod: uint64((100 * 365 * 24 * time.Hour) / time.Second), MaxClockDrift: uint64((100 * 365 * 24 * time.Hour) / time.Second), @@ -178,6 +182,7 @@ func TestLive_InboundLegEndToEnd(t *testing.T) { "a proof must not authenticate a packet other than the one committed") } +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func liveDeploy(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address) common.Address { t.Helper() @@ -189,6 +194,7 @@ func liveDeploy(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.Pri return crypto.CreateAddress(from, nonce-1) } +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func liveSend(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address, to *common.Address, data []byte) uint64 { t.Helper() @@ -223,6 +229,7 @@ func liveSend(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.Priva return 0 } +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func liveNonce(t *testing.T, ctx context.Context, c *rpc.Client, addr common.Address) uint64 { t.Helper() var n hexutil.Uint64 diff --git a/tests/integration/qbft_pilot_test.go b/tests/integration/qbft_pilot_test.go index 50b2fa36..b9c4524c 100644 --- a/tests/integration/qbft_pilot_test.go +++ b/tests/integration/qbft_pilot_test.go @@ -54,6 +54,7 @@ func TestQBFTPilot_DeploySteps(t *testing.T) { // 5.4a — create the QBFT client, as cmd/qbftinit would. keys := qbfttestutil.Keys(t, 4) state := qbfttestutil.NewState(t, qbftContract, map[common.Hash][]byte{}) + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap now := uint64(ctx.BlockTime().Unix()) genesis := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ diff --git a/tests/integration/qbftclient_test.go b/tests/integration/qbftclient_test.go index ef4581e8..5def2895 100644 --- a/tests/integration/qbftclient_test.go +++ b/tests/integration/qbftclient_test.go @@ -26,7 +26,7 @@ const ( var qbftContract = common.HexToAddress("0x00000000000000000000000000000000cafebabe") -// TestQBFTClient_Lifecycle drives create → update → membership → misbehaviour → +// TestQBFTClient_Lifecycle drives create → update → membership → misbehavior → // recover through the real ClientKeeper. // // The unit tests prove the verification logic; this proves the adapter: that the @@ -52,6 +52,7 @@ func TestQBFTClient_Lifecycle(t *testing.T) { state := qbfttestutil.NewState(t, qbftContract, map[common.Hash][]byte{slot: commitment.Bytes()}) ctx := chain.GetContext() + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap now := uint64(ctx.BlockTime().Unix()) genesis := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ @@ -147,7 +148,7 @@ func TestQBFTClient_Lifecycle(t *testing.T) { "a header from outside the validator set must not update the client") }) - t.Run("misbehaviour freezes the client", func(t *testing.T) { + t.Run("misbehavior freezes the client", func(t *testing.T) { // Two headers at one height, both properly sealed, different block times — // so different block hashes. QBFT is instantly final, so this is equivocation. h1 := qbfttestutil.SealedHeader(t, keys, qbfttestutil.HeaderOpts{ @@ -162,7 +163,7 @@ func TestQBFTClient_Lifecycle(t *testing.T) { Header_2: qbfttestutil.RLP(t, h2), } - require.NoError(t, k.UpdateClient(ctx, clientID, msg), "valid misbehaviour must be accepted") + require.NoError(t, k.UpdateClient(ctx, clientID, msg), "valid misbehavior must be accepted") require.Equal(t, exported.Frozen, k.GetClientStatus(ctx, clientID), "equivocation must freeze the client") }) @@ -195,6 +196,7 @@ func TestQBFTClient_RejectsMismatchedSubstitute(t *testing.T) { cdc := a.AppCodec() k := a.GetIBCKeeper().ClientKeeper ctx := chain.GetContext() + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap now := uint64(ctx.BlockTime().Unix()) keys := qbfttestutil.Keys(t, 4) diff --git a/x/cbdc/keeper/msg_server.go b/x/cbdc/keeper/msg_server.go index b2b7657e..8dbc56da 100644 --- a/x/cbdc/keeper/msg_server.go +++ b/x/cbdc/keeper/msg_server.go @@ -75,7 +75,7 @@ func (k msgServer) UpdateParams(goCtx context.Context, msg *types.MsgUpdateParam } // SetDenomMetadata overwrites bank denom metadata. It exists because ibc-go -// synthesises voucher metadata from the denom string alone and only writes it +// synthesizes voucher metadata from the denom string alone and only writes it // when none exists yet, so a voucher that arrives before its metadata is // seeded is stuck with 0 decimals forever — this is the post-genesis // correction path. It is gated on the gov authority rather than the mint/burn diff --git a/x/qbftclient/attestor/aggregatorpb/service.go b/x/qbftclient/attestor/aggregatorpb/service.go index c5a11ec5..b9c66c20 100644 --- a/x/qbftclient/attestor/aggregatorpb/service.go +++ b/x/qbftclient/attestor/aggregatorpb/service.go @@ -1,3 +1,11 @@ +// This file is written by hand but mirrors protoc-gen-go-grpc's output shape, +// because the proto-builder image ships protoc-gen-go without the grpc plugin. +// That shape is the point: it must stay diffable against what the generator +// would emit, so the handler signatures keep `srv any` before `ctx` and the +// service descriptor keeps its generated `Xxx_ServiceDesc` name. revive objects +// to both; matching upstream codegen wins over house style here. +// +//nolint:revive,stylecheck // mirrors protoc-gen-go-grpc output; see note above package aggregatorpb // gRPC glue for AggregatorService, hand-written because the proto-builder image diff --git a/x/qbftclient/light_client_module.go b/x/qbftclient/light_client_module.go index 759f2e92..7ddfec8a 100644 --- a/x/qbftclient/light_client_module.go +++ b/x/qbftclient/light_client_module.go @@ -96,7 +96,7 @@ func (l LightClientModule) VerifyClientMessage(ctx sdk.Context, clientID string, // CheckForMisbehaviour reports whether the message is evidence of equivocation. // VerifyClientMessage has already run, so a Misbehaviour reaching here is valid. -func (l LightClientModule) CheckForMisbehaviour(ctx sdk.Context, clientID string, clientMsg exported.ClientMessage) bool { +func (l LightClientModule) CheckForMisbehaviour(_ sdk.Context, _ string, clientMsg exported.ClientMessage) bool { _, ok := clientMsg.(*types.Misbehaviour) return ok } diff --git a/x/qbftclient/proofapipb/service.go b/x/qbftclient/proofapipb/service.go index b2f3084f..04f34384 100644 --- a/x/qbftclient/proofapipb/service.go +++ b/x/qbftclient/proofapipb/service.go @@ -1,3 +1,11 @@ +// This file is written by hand but mirrors protoc-gen-go-grpc's output shape, +// because the proto-builder image ships protoc-gen-go without the grpc plugin. +// That shape is the point: it must stay diffable against what the generator +// would emit, so the handler signatures keep `srv any` before `ctx` and the +// service descriptor keeps its generated `Xxx_ServiceDesc` name. revive objects +// to both; matching upstream codegen wins over house style here. +// +//nolint:revive,stylecheck // mirrors protoc-gen-go-grpc output; see note above package proofapipb // gRPC glue for ProofApiService, hand-written because the proto-builder image diff --git a/x/qbftclient/prover/besu/client.go b/x/qbftclient/prover/besu/client.go index 131a0b1a..08992e6b 100644 --- a/x/qbftclient/prover/besu/client.go +++ b/x/qbftclient/prover/besu/client.go @@ -32,7 +32,7 @@ type Client struct { func Dial(ctx context.Context, url string) (*Client, error) { c, err := rpc.DialContext(ctx, url) if err != nil { - return nil, fmt.Errorf("besu: dialling %s: %w", url, err) + return nil, fmt.Errorf("besu: dialing %s: %w", url, err) } return New(c), nil } diff --git a/x/qbftclient/prover/besu/live_storage_test.go b/x/qbftclient/prover/besu/live_storage_test.go index 06f50a8f..c431aab8 100644 --- a/x/qbftclient/prover/besu/live_storage_test.go +++ b/x/qbftclient/prover/besu/live_storage_test.go @@ -19,7 +19,7 @@ import ( "github.com/peersyst/cbdc-node/x/qbftclient/types" ) -// storageWriterCode deploys a contract whose whole behaviour is +// storageWriterCode deploys a contract whose whole behavior is // `sstore(calldata[0:32], calldata[32:64])`. // // It stands in for ICS20Transfer for one purpose only: writing a chosen 32-byte @@ -36,6 +36,8 @@ const storageWriterCode = "0x6008600c60003960086000f36020356000355500" // Scenario A spoke is the same chain with a different chainId injected, so a // hardcoded value silently restricts this test to whichever spoke it was written // against -- it fails with "Wrong chainId" on every other one. +// +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func chainIDOf(t *testing.T, ctx context.Context, c *rpc.Client) *big.Int { t.Helper() @@ -129,6 +131,7 @@ func TestLive_PacketCommitmentSlotProvesAgainstRealStorage(t *testing.T) { } } +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func deploy(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address) common.Address { t.Helper() @@ -145,6 +148,8 @@ func deploy(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.Private // send signs and submits a transaction, waits for it to be mined, and returns the // block it landed in. +// +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func send(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKey, from common.Address, to *common.Address, data []byte) uint64 { t.Helper() @@ -191,6 +196,7 @@ func send(t *testing.T, ctx context.Context, c *rpc.Client, key *ecdsa.PrivateKe return 0 } +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func nonceOf(t *testing.T, ctx context.Context, c *rpc.Client, addr common.Address) uint64 { t.Helper() var n hexutil.Uint64 diff --git a/x/qbftclient/prover/besu/live_test.go b/x/qbftclient/prover/besu/live_test.go index 3d8b3e3c..260a0458 100644 --- a/x/qbftclient/prover/besu/live_test.go +++ b/x/qbftclient/prover/besu/live_test.go @@ -141,6 +141,8 @@ func TestLive_AccountProofVerifies(t *testing.T) { // latestHeight reads the chain head, so proofs can be taken at a height whose world // state Besu still retains. +// +//nolint:revive // t *testing.T before ctx is the Go test-helper convention func latestHeight(t *testing.T, ctx context.Context) uint64 { t.Helper() diff --git a/x/qbftclient/prover/cosmos/prover.go b/x/qbftclient/prover/cosmos/prover.go index e36d14a9..86ca01df 100644 --- a/x/qbftclient/prover/cosmos/prover.go +++ b/x/qbftclient/prover/cosmos/prover.go @@ -50,7 +50,7 @@ type RPC interface { type Proof struct { // Value is the stored bytes — a commitment hash, or empty for an absence proof. Value []byte - // Proof is the marshalled ICS-23 merkle proof. + // Proof is the marshaled ICS-23 merkle proof. Proof []byte // Height is the height the counterparty must verify the proof at. It is one // above the queried height; see SettledHeight. @@ -180,7 +180,7 @@ func (p *Prover) query(ctx context.Context, key []byte, height int64) (*Proof, e } bz, err := p.cdc.Marshal(&merkleProof) if err != nil { - return nil, fmt.Errorf("cosmos: marshalling merkle proof: %w", err) + return nil, fmt.Errorf("cosmos: marshaling merkle proof: %w", err) } return &Proof{ diff --git a/x/qbftclient/prover/cosmos/prover_test.go b/x/qbftclient/prover/cosmos/prover_test.go index c958901d..6aa8b11e 100644 --- a/x/qbftclient/prover/cosmos/prover_test.go +++ b/x/qbftclient/prover/cosmos/prover_test.go @@ -67,8 +67,15 @@ func newCodec() *codec.ProtoCodec { return codec.NewProtoCodec(codectypes.NewInterfaceRegistry()) } -func packetFor(t *testing.T, client string, sequence uint64) channeltypesv2.Packet { +// packetFor builds the one packet every test in this file uses. Both the client +// and the sequence are fixed: no test here varies them, and naming them as +// constants beats threading arguments that only ever take one value. +func packetFor(t *testing.T) channeltypesv2.Packet { t.Helper() + const ( + client = "07-tendermint-0" + sequence = uint64(7) + ) return channeltypesv2.Packet{ Sequence: sequence, SourceClient: client, @@ -107,7 +114,7 @@ func TestSettledHeight_ChainTooShort(t *testing.T) { } func TestPacketCommitment(t *testing.T) { - packet := packetFor(t, "07-tendermint-0", 7) + packet := packetFor(t) rpc := &fakeRPC{resp: abci.ResponseQuery{ Value: channeltypesv2.CommitPacket(packet), @@ -133,8 +140,8 @@ func TestPacketCommitment(t *testing.T) { // stored commitment while submitting a different packet is rejected on the // counterparty, long after this tool has exited. func TestPacketCommitment_PacketDoesNotMatchCommitment(t *testing.T) { - stored := packetFor(t, "07-tendermint-0", 7) - submitted := packetFor(t, "07-tendermint-0", 7) + stored := packetFor(t) + submitted := packetFor(t) submitted.TimeoutTimestamp = 2_000 // same client and sequence, different packet rpc := &fakeRPC{resp: abci.ResponseQuery{ @@ -150,7 +157,7 @@ func TestPacketCommitment_PacketDoesNotMatchCommitment(t *testing.T) { } func TestPacketCommitment_Missing(t *testing.T) { - packet := packetFor(t, "07-tendermint-0", 7) + packet := packetFor(t) rpc := &fakeRPC{resp: abci.ResponseQuery{Height: 99, ProofOps: validProofOps(t)}} _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 99) @@ -163,7 +170,7 @@ func TestPacketCommitment_Missing(t *testing.T) { // explicit check it reads as "no commitment", which sends the operator looking for // the wrong problem. func TestQuery_AppLevelFailureIsNotMistakenForAbsence(t *testing.T) { - packet := packetFor(t, "07-tendermint-0", 7) + packet := packetFor(t) rpc := &fakeRPC{resp: abci.ResponseQuery{Code: 18, Log: "height 42 is not available"}} _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 42) @@ -173,7 +180,7 @@ func TestQuery_AppLevelFailureIsNotMistakenForAbsence(t *testing.T) { } func TestQuery_MissingProofOps(t *testing.T) { - packet := packetFor(t, "07-tendermint-0", 7) + packet := packetFor(t) rpc := &fakeRPC{resp: abci.ResponseQuery{Value: channeltypesv2.CommitPacket(packet), Height: 99}} _, err := cosmos.New(rpc, newCodec(), chainID).PacketCommitment(context.Background(), packet, 99) diff --git a/x/qbftclient/prover/msgs/msgs.go b/x/qbftclient/prover/msgs/msgs.go index ef95adc8..0a7ca50d 100644 --- a/x/qbftclient/prover/msgs/msgs.go +++ b/x/qbftclient/prover/msgs/msgs.go @@ -123,7 +123,7 @@ func marshalProof(cdc codec.BinaryCodec, proof *types.StorageProof) ([]byte, err } bz, err := cdc.Marshal(proof) if err != nil { - return nil, fmt.Errorf("msgs: marshalling storage proof: %w", err) + return nil, fmt.Errorf("msgs: marshaling storage proof: %w", err) } return bz, nil } diff --git a/x/qbftclient/prover/prover_test.go b/x/qbftclient/prover/prover_test.go index 524a3ab5..4e199295 100644 --- a/x/qbftclient/prover/prover_test.go +++ b/x/qbftclient/prover/prover_test.go @@ -144,6 +144,7 @@ func TestUpdateChain_AcrossSetChange(t *testing.T) { if err != nil { t.Fatalf("header %d: %v", i, err) } + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap now := time.Unix(int64(h.Time), 0).UTC() if err := types.VerifyHeader(trusted, h, now, trustingPeriod, maxClockDrift); err != nil { t.Fatalf("header %d (height %d) does not verify against the previous set: %v", i, h.Number, err) diff --git a/x/qbftclient/types/client_message.go b/x/qbftclient/types/client_message.go index 59222b4c..3abc0c79 100644 --- a/x/qbftclient/types/client_message.go +++ b/x/qbftclient/types/client_message.go @@ -48,7 +48,7 @@ func (m *Misbehaviour) ClientType() string { return ClientType } // // The RLP is kept rather than parsed fields precisely so this decode is the only // interpretation step: the block hash is taken over these bytes, so anything that -// re-serialises risks a digest Besu would not recognise. +// re-serializes risks a digest Besu would not recognize. func (h *Header) EthHeader() (*ethtypes.Header, error) { var out ethtypes.Header if err := rlp.DecodeBytes(h.RlpHeader, &out); err != nil { @@ -81,11 +81,11 @@ func (m *Misbehaviour) Headers() (*ethtypes.Header, *ethtypes.Header, error) { eth1, err := h1.EthHeader() if err != nil { - return nil, nil, fmt.Errorf("qbft: first misbehaviour header: %w", err) + return nil, nil, fmt.Errorf("qbft: first misbehavior header: %w", err) } eth2, err := h2.EthHeader() if err != nil { - return nil, nil, fmt.Errorf("qbft: second misbehaviour header: %w", err) + return nil, nil, fmt.Errorf("qbft: second misbehavior header: %w", err) } return eth1, eth2, nil } diff --git a/x/qbftclient/types/extradata.go b/x/qbftclient/types/extradata.go index e1c13dac..c1f6111b 100644 --- a/x/qbftclient/types/extradata.go +++ b/x/qbftclient/types/extradata.go @@ -94,7 +94,7 @@ func (e *ExtraData) EncodeWithoutCommitSealsAndRound() ([]byte, error) { } // EncodeRLP implements rlp.Encoder. An empty vote and an empty seal list must both -// serialise as empty RLP lists rather than as empty byte strings, which is what a +// serialize as empty RLP lists rather than as empty byte strings, which is what a // nil slice would otherwise produce. func (e *ExtraData) EncodeRLP(w io.Writer) error { vote := e.Vote diff --git a/x/qbftclient/types/state.go b/x/qbftclient/types/state.go index a8d0a1fd..f26132b3 100644 --- a/x/qbftclient/types/state.go +++ b/x/qbftclient/types/state.go @@ -41,7 +41,7 @@ func (cs *ClientState) Validate() error { return nil } -// IsFrozen reports whether misbehaviour has frozen this client. +// IsFrozen reports whether misbehavior has frozen this client. func (cs *ClientState) IsFrozen() bool { return cs.FrozenHeight != 0 } // ContractAddress returns the counterparty IBC contract whose storage holds packet diff --git a/x/qbftclient/types/update.go b/x/qbftclient/types/update.go index 2e815d0c..d0d76b74 100644 --- a/x/qbftclient/types/update.go +++ b/x/qbftclient/types/update.go @@ -22,12 +22,12 @@ var ( // ErrFromTheFuture is returned when a header's timestamp is further ahead of // local time than the configured clock drift allows. ErrFromTheFuture = errors.New("qbft: header timestamp beyond max clock drift") - // ErrNotSameHeight is returned when a misbehaviour submission carries headers + // ErrNotSameHeight is returned when a misbehavior submission carries headers // at different heights, which is not evidence of anything. - ErrNotSameHeight = errors.New("qbft: misbehaviour headers are at different heights") - // ErrSameBlock is returned when a misbehaviour submission carries the same + ErrNotSameHeight = errors.New("qbft: misbehavior headers are at different heights") + // ErrSameBlock is returned when a misbehavior submission carries the same // block twice. - ErrSameBlock = errors.New("qbft: misbehaviour headers are the same block") + ErrSameBlock = errors.New("qbft: misbehavior headers are the same block") ) // TrustedState is what the client already believes: the height it has verified, @@ -129,13 +129,13 @@ func DetectMisbehaviour(trusted TrustedState, h1, h2 *ethtypes.Header) (bool, er } // Both must be genuinely sealed by the trusted set. One valid header beside a - // forgery is not misbehaviour, and freezing on it would let anyone disable the + // forgery is not misbehavior, and freezing on it would let anyone disable the // corridor with a fabricated second header. if err := VerifyCommitSeals(h1, trusted.Validators); err != nil { - return false, fmt.Errorf("qbft: first misbehaviour header: %w", err) + return false, fmt.Errorf("qbft: first misbehavior header: %w", err) } if err := VerifyCommitSeals(h2, trusted.Validators); err != nil { - return false, fmt.Errorf("qbft: second misbehaviour header: %w", err) + return false, fmt.Errorf("qbft: second misbehavior header: %w", err) } return true, nil diff --git a/x/qbftclient/types/update_test.go b/x/qbftclient/types/update_test.go index c9e7024a..1f606720 100644 --- a/x/qbftclient/types/update_test.go +++ b/x/qbftclient/types/update_test.go @@ -72,6 +72,7 @@ func TestVerifyHeader_TrustingPeriodExpired(t *testing.T) { elapsed := uint64(trustingPeriod/time.Second) + 1 h := sealedHeaderAt(t, keys, 3, 11, start+elapsed) + //nolint:gosec // block time is always after the unix epoch, so the conversion cannot wrap err := types.VerifyHeader(trusted, h, time.Unix(int64(start+elapsed), 0).UTC(), trustingPeriod, maxClockDrift) if !errors.Is(err, types.ErrTrustingPeriodExpired) { t.Errorf("want ErrTrustingPeriodExpired, got %v", err) @@ -195,7 +196,7 @@ func TestDetectMisbehaviour(t *testing.T) { t.Fatalf("DetectMisbehaviour: %v", err) } if !got { - t.Error("two validly-sealed conflicting headers should be misbehaviour") + t.Error("two validly-sealed conflicting headers should be misbehavior") } } diff --git a/x/qbftclient/types/verify.go b/x/qbftclient/types/verify.go index bd896230..67cedfa9 100644 --- a/x/qbftclient/types/verify.go +++ b/x/qbftclient/types/verify.go @@ -36,7 +36,7 @@ func RequiredQuorum(validatorCount int) int { // // validators is the set trusted *for this height*. Under the chain-following model // that is the set carried by the last verified header, never the set carried by h — -// a header cannot authorise the validator set that vouches for it. +// a header cannot authorize the validator set that vouches for it. func VerifyCommitSeals(h *ethtypes.Header, validators []common.Address) error { if len(validators) == 0 { return ErrNoValidators diff --git a/x/qbftclient/types/verify_test.go b/x/qbftclient/types/verify_test.go index 4dd2949f..d1424687 100644 --- a/x/qbftclient/types/verify_test.go +++ b/x/qbftclient/types/verify_test.go @@ -13,7 +13,7 @@ import ( ) // TestRequiredQuorum pins the threshold to Besu's, since a divergence here either -// accepts headers Besu considers unfinal or rejects ones it finalised. +// accepts headers Besu considers unfinal or rejects ones it finalized. // // Besu: BftHelpers.calculateRequiredValidatorQuorum = fastDivCeiling(2n, 3). func TestRequiredQuorum(t *testing.T) { From 9822c7332c57ccd82aa9a0ac6b1d7cb5d7893747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 50/61] fix(qbftproofapi): repair a torn guard log instead of forgetting heights --- cmd/qbftproofapi/guard.go | 43 +++++++++++++++++----- cmd/qbftproofapi/guard_test.go | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/cmd/qbftproofapi/guard.go b/cmd/qbftproofapi/guard.go index 62430b74..49678210 100644 --- a/cmd/qbftproofapi/guard.go +++ b/cmd/qbftproofapi/guard.go @@ -33,10 +33,13 @@ package main // 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" @@ -207,23 +210,47 @@ func (s *server) provenAbsent(ctx context.Context, path []byte, height uint64) e } // 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) - f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o600) - if err != nil { + raw, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } - dec := json.NewDecoder(f) - for { - var r seenRecord - if err := dec.Decode(&r); err != nil { + 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 } - g.seen[r.Height] = r.Timestamp + 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 } - f.Close() g.seenLog, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err != nil { diff --git a/cmd/qbftproofapi/guard_test.go b/cmd/qbftproofapi/guard_test.go index 1cf1d20d..0dea8387 100644 --- a/cmd/qbftproofapi/guard_test.go +++ b/cmd/qbftproofapi/guard_test.go @@ -11,6 +11,7 @@ package main import ( "context" "os" + "path/filepath" "testing" "time" @@ -116,6 +117,70 @@ func TestGuardHeight_SurvivesRestart(t *testing.T) { } } +// 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. From 5114ce7a52b1e98d7b204890949383ebee564e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 51/61] fix(corridord): fail closed when an abci query fails --- cmd/corridord/chains.go | 19 +++++++++++++ cmd/corridord/chains_test.go | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 cmd/corridord/chains_test.go diff --git a/cmd/corridord/chains.go b/cmd/corridord/chains.go index ec06724b..bbf5b985 100644 --- a/cmd/corridord/chains.go +++ b/cmd/corridord/chains.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" @@ -283,6 +284,14 @@ func (c *cbdcRPC) get(ctx context.Context, path string, out any) error { 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) } @@ -469,6 +478,8 @@ func (c *cbdcRPC) storeValueSet(ctx context.Context, path []byte) (bool, error) var out struct { Result struct { Response struct { + Code uint32 `json:"code"` + Log string `json:"log"` Value string `json:"value"` } `json:"response"` } `json:"result"` @@ -476,6 +487,14 @@ func (c *cbdcRPC) storeValueSet(ctx context.Context, path []byte) (bool, error) 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 } diff --git a/cmd/corridord/chains_test.go b/cmd/corridord/chains_test.go new file mode 100644 index 00000000..c7088741 --- /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) + } + }) + } +} From 8c020fcd44b6a882e73445101799a8011514ddfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 52/61] fix(qbftclient): bound seal recovery by the trusted validator set --- x/qbftclient/types/verify.go | 18 ++++++++++++++++++ x/qbftclient/types/verify_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/x/qbftclient/types/verify.go b/x/qbftclient/types/verify.go index 67cedfa9..ee5780bc 100644 --- a/x/qbftclient/types/verify.go +++ b/x/qbftclient/types/verify.go @@ -17,6 +17,11 @@ var ( // ErrQuorumNotMet is returned when a header carries fewer distinct seals than // QBFT finality requires. ErrQuorumNotMet = errors.New("qbft: committed seals below quorum") + // ErrTooManySeals is returned when a header carries more committed seals than + // the trusted validator set has members. Such a header is invalid by + // construction -- it must repeat a sealer or include a non-member -- so it is + // rejected before any signature recovery. + ErrTooManySeals = errors.New("qbft: more committed seals than validators") ) // RequiredQuorum returns the number of distinct committed seals a QBFT header must @@ -42,6 +47,19 @@ func VerifyCommitSeals(h *ethtypes.Header, validators []common.Address) error { return ErrNoValidators } + // A valid header carries at most one seal per trusted validator: every + // committer must be a distinct member of the set that sealed it. Rejecting on + // the seal count before recovery bounds the signature-recovery work an + // attacker-supplied header can force to len(validators), and rejects nothing + // the membership and duplicate checks below would have accepted. + e, err := DecodeExtraData(h.Extra) + if err != nil { + return err + } + if len(e.Seals) > len(validators) { + return fmt.Errorf("%w: %d seals, %d validators", ErrTooManySeals, len(e.Seals), len(validators)) + } + committers, err := RecoverCommitters(h) if err != nil { return err diff --git a/x/qbftclient/types/verify_test.go b/x/qbftclient/types/verify_test.go index d1424687..44bdac61 100644 --- a/x/qbftclient/types/verify_test.go +++ b/x/qbftclient/types/verify_test.go @@ -158,3 +158,31 @@ func TestExtraDataRoundTrip(t *testing.T) { t.Errorf("round trip changed extraData:\n got %x\nwant %x", reencoded, original) } } + +// A header carrying more committed seals than the trusted set has members is +// invalid by construction: QBFT gives each validator one seal and every committer +// must be a member, so at most len(validators) distinct seals can be valid. +// VerifyCommitSeals must reject such a header on the count alone, before recovering +// any signature -- this is what bounds the elliptic-curve work an attacker-supplied +// header can force (a ~1MB extraData is ~16k seals). +// +// The header here is sealed by five validators but checked against a trusted set of +// three. That the error is ErrTooManySeals and not ErrUnknownCommitter -- two of the +// five signers are outside the trusted set -- is what proves the count gate ran +// before any ecrecover. +func TestVerifyCommitSeals_TooManySeals(t *testing.T) { + keys := genKeys(t, 5) + trusted := validatorsOf(keys[:3]) + + if err := types.VerifyCommitSeals(sealedHeader(t, keys, 5), trusted); !errors.Is(err, types.ErrTooManySeals) { + t.Errorf("five seals against three validators should be rejected on count, got %v", err) + } + + // The bound is inclusive: a super-quorum header where every trusted validator + // sealed carries exactly len(validators) seals and must still verify. This + // guards against the gate being written as >= . + full := genKeys(t, 4) + if err := types.VerifyCommitSeals(sealedHeader(t, full, 4), validatorsOf(full)); err != nil { + t.Errorf("all four validators sealing should verify, got %v", err) + } +} From f8c516eee991543e063b3416b33e3aebb468bf1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 53/61] fix(qbftaggregator): require distinct attestors to meet the threshold --- cmd/qbftaggregator/main.go | 46 ++++++++++++++++--- cmd/qbftaggregator/main_test.go | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/cmd/qbftaggregator/main.go b/cmd/qbftaggregator/main.go index c565788b..429777e6 100644 --- a/cmd/qbftaggregator/main.go +++ b/cmd/qbftaggregator/main.go @@ -72,7 +72,10 @@ func main() { if *attestors == "" || *minSigs <= 0 { log.Fatal("required: -attestors -min-sigs") } - addrs := strings.Split(*attestors, ",") + 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)) } @@ -84,7 +87,6 @@ func main() { a := &aggregator{minSigs: *minSigs, timeout: *timeout} for _, addr := range addrs { - addr = strings.TrimSpace(addr) conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatalf("attestor %s: %v", addr, err) @@ -98,10 +100,31 @@ func main() { } srv := grpc.NewServer() pb.RegisterAggregatorServiceServer(srv, a) - log.Printf("qbftaggregator on %s: %d-of-%d over %s", *listen, *minSigs, len(a.backends), *attestors) + 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 @@ -167,7 +190,7 @@ func (a *aggregator) merge( kind string, ) (*pb.AggregatedAttestation, error) { var merged *pb.AggregatedAttestation - var sigs [][]byte + sigs := make([][]byte, 0, len(results)) var refused []string for _, r := range results { @@ -199,16 +222,25 @@ func (a *aggregator) merge( "Two attestors disagree about cbdc-node's state; do not retry, investigate which one is wrong", kind, att.GetHeight(), r.addr) } - sigs = append(sigs, att.GetSignatures()...) + // 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 %d signature(s), need %d: %s", + "%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 %d/%d signatures; refusals: %s", + 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 diff --git a/cmd/qbftaggregator/main_test.go b/cmd/qbftaggregator/main_test.go index 0dff2b78..d434e60f 100644 --- a/cmd/qbftaggregator/main_test.go +++ b/cmd/qbftaggregator/main_test.go @@ -108,3 +108,84 @@ func TestMerge_DoesNotResolveDivergenceByMajority(t *testing.T) { 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)) + } +} From 1e1f9ce2e507e4ef18fa5e82f741c693d6ef4fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 54/61] fix(attestcheck): guard the chain-height underflow before subtracting --- cmd/attestcheck/main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/attestcheck/main.go b/cmd/attestcheck/main.go index 64ffd21c..8300fac1 100644 --- a/cmd/attestcheck/main.go +++ b/cmd/attestcheck/main.go @@ -62,10 +62,12 @@ func main() { if err != nil { log.Fatalf("cbdc rpc: %v", err) } - height := tip - 5 - if height < 2 { + // 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 { From b64f61a72d7d28009eae1ca17c21cf09f544e637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 55/61] feat(cbdc): emit a corridor pause event at the governance action --- app/ibc/corridorpause/middleware.go | 21 ++++--- app/ibc/corridorpause/middleware_test.go | 30 ++++++++-- x/cbdc/keeper/msg_server.go | 27 +++++++++ .../keeper/msg_server_update_params_test.go | 56 +++++++++++++++++++ x/cbdc/types/events.go | 9 +++ 5 files changed, 130 insertions(+), 13 deletions(-) diff --git a/app/ibc/corridorpause/middleware.go b/app/ibc/corridorpause/middleware.go index 80b61b4b..64b17e3f 100644 --- a/app/ibc/corridorpause/middleware.go +++ b/app/ibc/corridorpause/middleware.go @@ -27,10 +27,20 @@ import ( "github.com/cosmos/ibc-go/v10/modules/core/api" ) -// Events emitted when a corridor pause takes effect. They exist so a pause is -// visible in the block stream rather than only as a failed transaction. +// 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 ( - EventTypeSendPaused = "ibc_corridor_send_paused" EventTypeRecvPaused = "ibc_corridor_recv_paused" AttributeKeyClientID = "client_id" AttributeKeySequence = "sequence" @@ -68,11 +78,6 @@ func (im IBCMiddleware) OnSendPacket( signer sdk.AccAddress, ) error { if im.params.IsIBCClientPaused(ctx, sourceClient) { - ctx.EventManager().EmitEvent(sdk.NewEvent( - EventTypeSendPaused, - sdk.NewAttribute(AttributeKeyClientID, sourceClient), - sdk.NewAttribute(AttributeKeySequence, fmt.Sprint(sequence)), - )) return fmt.Errorf("ibc corridor %s is paused by governance", sourceClient) } return im.app.OnSendPacket(ctx, sourceClient, destinationClient, sequence, payload, signer) diff --git a/app/ibc/corridorpause/middleware_test.go b/app/ibc/corridorpause/middleware_test.go index 2c9d8c0a..b74a8fbd 100644 --- a/app/ibc/corridorpause/middleware_test.go +++ b/app/ibc/corridorpause/middleware_test.go @@ -61,9 +61,11 @@ func TestSendIsRefusedOnAPausedCorridor(t *testing.T) { t.Error("the packet must not reach the transfer stack — nothing may be escrowed") } - // The pause has to be visible in the block stream, not only as a failed tx. - if len(ctx.EventManager().Events()) == 0 { - t.Error("a refused send must emit an event") + // 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)) } } @@ -96,8 +98,26 @@ func TestRecvIsRejectedWithAFailureAck(t *testing.T) { if app.recvd { t.Error("the packet must not reach the transfer stack — no voucher may be minted") } - if len(ctx.EventManager().Events()) == 0 { - t.Error("a refused receive must emit an event") + // 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]) } } diff --git a/x/cbdc/keeper/msg_server.go b/x/cbdc/keeper/msg_server.go index 8dbc56da..3066337d 100644 --- a/x/cbdc/keeper/msg_server.go +++ b/x/cbdc/keeper/msg_server.go @@ -2,6 +2,8 @@ package keeper import ( "context" + "slices" + "strconv" "cosmossdk.io/errors" @@ -69,11 +71,36 @@ func (k msgServer) UpdateParams(goCtx context.Context, msg *types.MsgUpdateParam } ctx := sdk.UnwrapSDKContext(goCtx) + before := k.GetParams(ctx).PausedIbcClients k.SetParams(ctx, msg.Params) + // One event per corridor whose pause state changed, so operators can alarm + // on both pause and unpause without diffing params themselves. + for _, c := range msg.Params.PausedIbcClients { + if !slices.Contains(before, c) { + emitCorridorPause(ctx, c, true) + } + } + for _, c := range before { + if !slices.Contains(msg.Params.PausedIbcClients, c) { + emitCorridorPause(ctx, c, false) + } + } + return &types.MsgUpdateParamsResponse{}, nil } +// emitCorridorPause announces that one corridor was paused or unpaused. See +// types.EventTypeCorridorPause for why the signal lives here and not at the +// enforcement point. +func emitCorridorPause(ctx sdk.Context, clientID string, paused bool) { + ctx.EventManager().EmitEvent(sdk.NewEvent( + types.EventTypeCorridorPause, + sdk.NewAttribute(types.AttributeClientID, clientID), + sdk.NewAttribute(types.AttributePaused, strconv.FormatBool(paused)), + )) +} + // SetDenomMetadata overwrites bank denom metadata. It exists because ibc-go // synthesizes voucher metadata from the denom string alone and only writes it // when none exists yet, so a voucher that arrives before its metadata is diff --git a/x/cbdc/keeper/msg_server_update_params_test.go b/x/cbdc/keeper/msg_server_update_params_test.go index 5567a614..1b79ba9e 100644 --- a/x/cbdc/keeper/msg_server_update_params_test.go +++ b/x/cbdc/keeper/msg_server_update_params_test.go @@ -3,6 +3,7 @@ package keeper import ( "testing" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/peersyst/cbdc-node/x/cbdc/types" "github.com/stretchr/testify/require" ) @@ -70,3 +71,58 @@ func TestMsgServer_UpdateParams(t *testing.T) { }) } } + +// A pause is only actionable if operators can see it. The enforcement point +// cannot provide that signal: app/ibc/corridorpause refuses a send by failing +// the transaction, and a failed transaction's events are discarded, so the +// governance action that writes paused_ibc_clients emits it instead. One event +// per corridor whose state actually changed, so an alarm can watch both pause +// and unpause without diffing params itself. +func TestMsgServer_UpdateParams_EmitsCorridorPauseEvents(t *testing.T) { + const owner = "ethm1ef8ep2et20ja5s6r99tafld67kph72h7e0577u" + const clientA, clientB = "qbftclient-0", "qbftclient-1" + + pauseEvents := func(ctx sdk.Context) map[string]string { + out := map[string]string{} + for _, ev := range ctx.EventManager().Events() { + if ev.Type != types.EventTypeCorridorPause { + continue + } + var id, paused string + for _, a := range ev.Attributes { + switch a.Key { + case types.AttributeClientID: + id = a.Value + case types.AttributePaused: + paused = a.Value + } + } + out[id] = paused + } + return out + } + + cbdcKeeper, ctx := cbdcKeeperTestSetup(t) + msgServer := NewMsgServerImpl(*cbdcKeeper) + update := func(clients ...string) map[string]string { + ctx = ctx.WithEventManager(sdk.NewEventManager()) + _, err := msgServer.UpdateParams(ctx, &types.MsgUpdateParams{ + Authority: testGovAuthority, + Params: types.NewParams(owner, false, clients...), + }) + require.NoError(t, err) + return pauseEvents(ctx) + } + + require.Equal(t, map[string]string{clientA: "true"}, update(clientA), + "pausing a corridor must announce it") + + require.Equal(t, map[string]string{clientA: "false", clientB: "true"}, update(clientB), + "a swap must announce the unpause as well as the pause") + + require.Empty(t, update(clientB), + "an unchanged list must announce nothing -- a repeated alarm is a false alarm") + + require.Equal(t, map[string]string{clientB: "false"}, update(), + "clearing the list must announce the all-clear") +} diff --git a/x/cbdc/types/events.go b/x/cbdc/types/events.go index 908f9b7e..827a382b 100644 --- a/x/cbdc/types/events.go +++ b/x/cbdc/types/events.go @@ -6,4 +6,13 @@ const ( AttributeOwner = "owner" AttributeAddress = "address" AttributeAmount = "amount" + + // EventTypeCorridorPause announces a change to one IBC corridor's pause + // state. It is emitted at the governance action rather than at enforcement: + // app/ibc/corridorpause refuses a send by failing the transaction, and a + // failed transaction's events are discarded, so this is the only reliable + // pause signal covering both packet directions. + EventTypeCorridorPause = "cbdc_corridor_pause" + AttributeClientID = "client_id" + AttributePaused = "paused" // "true" or "false" ) From f49683b534854ec38f007046043a835841884a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 56/61] fix(qbftclient): clamp counterparty-supplied seconds instead of wrapping --- x/qbftclient/types/state.go | 62 ++++++++++++++++++++++++++++++-- x/qbftclient/types/state_test.go | 44 +++++++++++++++++++++++ x/qbftclient/types/update.go | 4 +-- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/x/qbftclient/types/state.go b/x/qbftclient/types/state.go index f26132b3..9928e9a8 100644 --- a/x/qbftclient/types/state.go +++ b/x/qbftclient/types/state.go @@ -3,6 +3,7 @@ package types import ( "errors" "fmt" + "math" "time" "github.com/ethereum/go-ethereum/common" @@ -20,6 +21,42 @@ var ( // consensus-state checks do not depend on go-ethereum's constant naming. const AddressLength = common.AddressLength +// maxUnixSeconds is the largest whole-second count that still has a nanosecond +// representation in int64 -- year 2262. It bounds both a duration in seconds and a +// unix timestamp in seconds. +const maxUnixSeconds int64 = math.MaxInt64 / int64(time.Second) + +// maxDurationSeconds is that same bound unsigned, so the proto's uint64 fields can +// be compared against it without being converted first -- which is the conversion +// being made safe. The second-valued fields arrive from the counterparty, so they +// are rejected above this in Validate AND clamped at the conversion sites: the +// getters have no error return, and a wrapped trusting period reads as a NEGATIVE +// duration. Clamping is to zero, never to the maximum -- a client that trusts +// nothing is recoverable, one that trusts forever is not. +const maxDurationSeconds = uint64(maxUnixSeconds) + +// timeFromSeconds converts a counterparty-supplied unix-seconds timestamp. +// go-ethereum types a header's Time as uint64 while time.Unix takes int64, so an +// out-of-range value WRAPS to a negative second count -- a pre-1970 date, which is +// older than any trusted state and so passes every freshness check downstream. +// Saturating to year 2262 instead makes those same checks reject it. One helper +// rather than a conversion per call site: the safe form has to be the easy one. +func timeFromSeconds(sec uint64) time.Time { + if sec > maxDurationSeconds { + return time.Unix(maxUnixSeconds, 0).UTC() + } + return time.Unix(int64(sec), 0).UTC() +} + +// timeFromNanos is the same, for the nanosecond timestamps the ibc-go consensus +// state interface is defined in. +func timeFromNanos(nanos uint64) time.Time { + if nanos > math.MaxInt64 { + return time.Unix(0, math.MaxInt64).UTC() + } + return time.Unix(0, int64(nanos)).UTC() +} + // Validate reports whether the client state is usable. It is deliberately strict // about the trusting period: a client created with a period longer than the // counterparty's own guarantees will happily verify headers nobody stands behind. @@ -30,6 +67,14 @@ func (cs *ClientState) Validate() error { if cs.TrustingPeriod == 0 { return fmt.Errorf("%w: trusting period must be set", ErrInvalidClientState) } + if cs.TrustingPeriod > maxDurationSeconds { + return fmt.Errorf("%w: trusting period %d s overflows a duration", + ErrInvalidClientState, cs.TrustingPeriod) + } + if cs.MaxClockDrift > maxDurationSeconds { + return fmt.Errorf("%w: max clock drift %d s overflows a duration", + ErrInvalidClientState, cs.MaxClockDrift) + } if len(cs.IbcContractAddress) != AddressLength { return fmt.Errorf("%w: ibc contract address must be %d bytes, got %d", ErrInvalidClientState, AddressLength, len(cs.IbcContractAddress)) @@ -54,11 +99,17 @@ func (cs *ClientState) ContractAddress() common.Address { // it in seconds because a duration message would pull in a well-known-type import // for a value that is never sub-second. func (cs *ClientState) TrustingPeriodDuration() time.Duration { + if cs.TrustingPeriod > maxDurationSeconds { + return 0 + } return time.Duration(cs.TrustingPeriod) * time.Second } // MaxClockDriftDuration returns the permitted clock drift as a duration. func (cs *ClientState) MaxClockDriftDuration() time.Duration { + if cs.MaxClockDrift > maxDurationSeconds { + return 0 + } return time.Duration(cs.MaxClockDrift) * time.Second } @@ -68,6 +119,10 @@ func (cs *ConsensusState) ValidateBasic() error { if cs.Timestamp == 0 { return fmt.Errorf("%w: timestamp must be set", ErrInvalidConsensusState) } + if cs.Timestamp > math.MaxInt64 { + return fmt.Errorf("%w: timestamp %d overflows int64 nanoseconds", + ErrInvalidConsensusState, cs.Timestamp) + } if len(cs.StateRoot) != common.HashLength { return fmt.Errorf("%w: state root must be %d bytes, got %d", ErrInvalidConsensusState, common.HashLength, len(cs.StateRoot)) @@ -103,7 +158,7 @@ func (cs *ConsensusState) ValidatorAddresses() []common.Address { func (cs *ConsensusState) Trusted(height uint64) TrustedState { return TrustedState{ Height: height, - Timestamp: time.Unix(0, int64(cs.Timestamp)).UTC(), + Timestamp: timeFromNanos(cs.Timestamp), Validators: cs.ValidatorAddresses(), } } @@ -126,7 +181,10 @@ func NewConsensusState(h *ethtypes.Header) (*ConsensusState, error) { } return &ConsensusState{ - Timestamp: uint64(time.Unix(int64(h.Time), 0).UnixNano()), + // timeFromSeconds takes a uint64 and saturates, so the result is always at or + // after the epoch and UnixNano is non-negative -- the conversion cannot wrap. + //nolint:gosec // see above + Timestamp: uint64(timeFromSeconds(h.Time).UnixNano()), StateRoot: h.Root.Bytes(), Validators: encoded, }, nil diff --git a/x/qbftclient/types/state_test.go b/x/qbftclient/types/state_test.go index 63ade464..71ea15f0 100644 --- a/x/qbftclient/types/state_test.go +++ b/x/qbftclient/types/state_test.go @@ -2,6 +2,7 @@ package types_test import ( "errors" + "math" "testing" "time" @@ -30,6 +31,12 @@ func TestClientStateValidate(t *testing.T) { "zero trusting period": func(cs *types.ClientState) { cs.TrustingPeriod = 0 }, "short address": func(cs *types.ClientState) { cs.IbcContractAddress = []byte{0x01} }, "frozen above latest": func(cs *types.ClientState) { cs.FrozenHeight = cs.LatestHeight + 1 }, + "trusting period overflows a duration": func(cs *types.ClientState) { + cs.TrustingPeriod = math.MaxUint64 + }, + "clock drift overflows a duration": func(cs *types.ClientState) { + cs.MaxClockDrift = math.MaxUint64 + }, } { t.Run(name, func(t *testing.T) { cs := validClientState() @@ -62,6 +69,40 @@ func TestClientStateDurations(t *testing.T) { } } +// Validate rejects an out-of-range period, but the getters have no error return, +// so they must also fail CLOSED on state that reached them anyway. Zero is the +// safe direction: a client that trusts nothing is recoverable, one that trusts +// forever is not. A wrapped conversion would produce a large NEGATIVE duration, +// which is what this guards. +func TestClientStateDurations_FailClosedOnOverflow(t *testing.T) { + cs := validClientState() + cs.TrustingPeriod = math.MaxUint64 + cs.MaxClockDrift = math.MaxUint64 + + if got := cs.TrustingPeriodDuration(); got != 0 { + t.Errorf("overflowing trusting period = %s, want 0", got) + } + if got := cs.MaxClockDriftDuration(); got != 0 { + t.Errorf("overflowing clock drift = %s, want 0", got) + } +} + +// Same property for the consensus timestamp: saturate into the far future, never +// wrap to a pre-epoch date. MaxUint64 nanoseconds wraps to -1, i.e. 1969, which +// reads as comfortably older than the trusted state and passes every freshness +// check. +func TestTrustedTimestamp_SaturatesRatherThanWraps(t *testing.T) { + cs := &types.ConsensusState{ + Timestamp: math.MaxUint64, + StateRoot: common.Hash{}.Bytes(), + Validators: [][]byte{common.Address{}.Bytes()}, + } + got := cs.Trusted(1).Timestamp + if got.Year() < 2262 { + t.Errorf("overflowing timestamp wrapped to %s, want a far-future saturation", got) + } +} + // A header that has been verified becomes the next consensus state, and that // consensus state must convert back into exactly the trusted state the core takes. // This is the seam between the generated protos and the version-independent core, @@ -147,6 +188,9 @@ func TestConsensusStateValidate_Rejects(t *testing.T) { "short state root": func(cs *types.ConsensusState) { cs.StateRoot = []byte{0x01} }, "no validators": func(cs *types.ConsensusState) { cs.Validators = nil }, "short validator": func(cs *types.ConsensusState) { cs.Validators = [][]byte{{0x01}} }, + "timestamp overflows int64 nanos": func(cs *types.ConsensusState) { + cs.Timestamp = math.MaxUint64 + }, } { t.Run(name, func(t *testing.T) { cs := base() diff --git a/x/qbftclient/types/update.go b/x/qbftclient/types/update.go index d0d76b74..ca038322 100644 --- a/x/qbftclient/types/update.go +++ b/x/qbftclient/types/update.go @@ -62,7 +62,7 @@ func VerifyHeader( } // Besu header timestamps are seconds since the epoch. - headerTime := time.Unix(int64(h.Time), 0).UTC() + headerTime := timeFromSeconds(h.Time) if !headerTime.After(trusted.Timestamp) { return fmt.Errorf("%w: header %s is not after trusted %s", ErrTimeRegression, headerTime, trusted.Timestamp) @@ -94,7 +94,7 @@ func NextTrustedState(h *ethtypes.Header) (TrustedState, error) { } return TrustedState{ Height: h.Number.Uint64(), - Timestamp: time.Unix(int64(h.Time), 0).UTC(), + Timestamp: timeFromSeconds(h.Time), Validators: validators, }, nil } From 38d0eee0066e21bb4cd3667b489c22628ba0fb0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 19 Aug 2026 11:51:32 +0200 Subject: [PATCH 57/61] feat(corridor): keep key material outside the worktree --- scripts/corridor/attestor-upstream.scenb.toml | 18 +++++--- scripts/corridor/corridor-env.sh | 11 ++++- scripts/corridor/keystore-import/main.go | 9 ++-- scripts/corridor/relayer-config.scenb.yml | 17 +++++--- scripts/corridor/relayer-config.yml | 14 +++--- scripts/corridor/relayer-keys.example.json | 7 ++- scripts/corridor/render-config.sh | 43 +++++++++++++++++++ scripts/corridor/up-corridor.sh | 6 ++- 8 files changed, 100 insertions(+), 25 deletions(-) create mode 100755 scripts/corridor/render-config.sh diff --git a/scripts/corridor/attestor-upstream.scenb.toml b/scripts/corridor/attestor-upstream.scenb.toml index ee051850..00c1af8a 100644 --- a/scripts/corridor/attestor-upstream.scenb.toml +++ b/scripts/corridor/attestor-upstream.scenb.toml @@ -3,12 +3,18 @@ # 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: -# ibc_attestor server --config scripts/corridor/attestor-upstream.scenb.toml \ -# --chain-type cosmos --signer-type local \ -# --keystore-password "$(cat scripts/corridor/attestor-keystore.scenb.pass)" +# 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 KEYSTORE MUST RECOVER 0x256f6899dD7d9b62769Ec802F4eb5aaC05296018. +# 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 @@ -42,4 +48,4 @@ url = "http://127.0.0.1:26657" # 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 = "scripts/corridor/attestor-keystore.scenb" +keystore_path = "@CORRIDOR_SECRETS@/attestor-keystore.scenb" diff --git a/scripts/corridor/corridor-env.sh b/scripts/corridor/corridor-env.sh index e234feb0..489b738f 100755 --- a/scripts/corridor/corridor-env.sh +++ b/scripts/corridor/corridor-env.sh @@ -27,6 +27,12 @@ __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}" + CBDC_CHAIN="${CBDC_CHAIN:-cbdc-honduras_5040000-1}" # chain identity CBDC_RPC="${CBDC_RPC:-http://127.0.0.1:26657}" BESU_RPC="${BESU_RPC:-http://127.0.0.1:8845}" @@ -117,7 +123,7 @@ fi RELAYER_ADDR=$("$HNLD" keys show "$RELAYER_KEY_NAME" -a \ --keyring-backend test --home "$CBDC_HOME" 2>/dev/null || true) -KEYSTORE="${KEYSTORE:-$CORRIDOR_HOME/scripts/corridor/attestor-keystore.scenb}" +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. @@ -137,7 +143,7 @@ export CBDC_CHAIN CBDC_RPC CBDC_HOME HNLD EVM_CHAIN_ID \ ROUTER TRANSFER LIGHT_CLIENT \ RELAYER_KEY_NAME RELAYER_ADDR \ ATTESTOR_IMAGE ATTESTOR_ADDR ATTESTOR_GRPC KEYSTORE KEYSTORE_PASSWORD_FILE \ - CORRIDOR_HOME LEG_DIR STATE_DIR PROOF_API + 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)}"; } @@ -150,4 +156,5 @@ __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 index 48bfad0e..a8340738 100644 --- a/scripts/corridor/keystore-import/main.go +++ b/scripts/corridor/keystore-import/main.go @@ -19,9 +19,12 @@ // Usage: // // go run ./scripts/corridor/keystore-import \ -// -in scripts/corridor/attestor-key.scenb.json \ -// -out scripts/corridor/attestor-keystore.scenb \ -// -password-file scripts/corridor/attestor-keystore.scenb.pass +// -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 ( diff --git a/scripts/corridor/relayer-config.scenb.yml b/scripts/corridor/relayer-config.scenb.yml index 566d51af..7042010f 100644 --- a/scripts/corridor/relayer-config.scenb.yml +++ b/scripts/corridor/relayer-config.scenb.yml @@ -8,8 +8,11 @@ # all differ, and pointing the relayer at the wrong one fails silently rather # than loudly. # -# Run: -# TZ=UTC bin/relayer --config scripts/corridor/relayer-config.scenb.yml +# 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. # @@ -49,8 +52,10 @@ ibcv2_proof_api: signing: # Keyed by chain id: 'cbdc-honduras_5040000-1' and '1337'. - # NOT committed -- see relayer-keys.example.json. - keys_path: 'scripts/corridor/relayer-keys.scenb.json' + # 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: @@ -116,6 +121,6 @@ chains: # non-validator peers and would serve reads just as well. rpc: 'http://127.0.0.1:8845' contracts: - ics_26_router_address: '0xb7AA3cb25020F302b5F7cB6B98dd5722B345b1ac' - ics_20_transfer_address: '0xF51939C25Eb80F86088EE95251F5fd14e49A4d71' + 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 index 506d3109..485a0755 100644 --- a/scripts/corridor/relayer-config.yml +++ b/scripts/corridor/relayer-config.yml @@ -11,8 +11,11 @@ # devnet's rows are still what the shared 'relayer' database collides against # (see the postgres note below). # -# Run: -# TZ=UTC bin/relayer --config scripts/corridor/relayer-config.yml +# 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 @@ -48,9 +51,10 @@ ibcv2_proof_api: grpc_tls_enabled: false signing: - # Keyed by chain id: 'cbdc_1449999-1' and '1337'. NOT committed -- see - # relayer-keys.example.json. - keys_path: 'scripts/corridor/relayer-keys.json' + # 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: diff --git a/scripts/corridor/relayer-keys.example.json b/scripts/corridor/relayer-keys.example.json index 4d9dd38e..a4278260 100644 --- a/scripts/corridor/relayer-keys.example.json +++ b/scripts/corridor/relayer-keys.example.json @@ -1,6 +1,7 @@ { "_comment": [ - "Template for the relayer's signing keys. Copy to relayer-keys.json and fill in.", + "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", @@ -12,7 +13,9 @@ "Get the Cosmos key with:", " bin/hnld keys unsafe-export-eth-key relayer --keyring-backend test --home .hnld-rig", "", - "relayer-keys.json is gitignored. Never commit a filled-in copy." + "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", diff --git a/scripts/corridor/render-config.sh b/scripts/corridor/render-config.sh new file mode 100755 index 00000000..72d00047 --- /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