Skip to content

Commit 3d0ee35

Browse files
committed
Merge remote-tracking branch 'origin/audit-fixes' into merge-339
2 parents 6d8d1cb + 9fd3a39 commit 3d0ee35

74 files changed

Lines changed: 6389 additions & 420 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ Push Chain ships exactly one custom precompile:
150150
|---|---|---|
151151
| `0xEC00000000000000000000000000000000000001` | `usigverifier` | Ed25519 signature verification (Solana signatures over `bytes32` digests), registered at the reserved Push range |
152152

153-
Gas cost: `4000` per `verifyEd25519` call. See [`precompiles/usigverifier/README.md`](../precompiles/usigverifier/README.md).
153+
Gas cost: `4000` per `verifyEd25519` call (fixed 32-byte digest), and `4000` plus `12` per 32-byte
154+
word of `message` for `verifyEd25519RawMessage`, whose message is hard-capped at 128 KiB. See
155+
[`precompiles/usigverifier/README.md`](../precompiles/usigverifier/README.md).
154156

155157
The baseline EVM precompiles (`bech32`, `p256`, `staking`, `distribution`, `ics20`, `bank`, `gov`, `slashing`, `evidence`) are wired in via `app/precompiles.go:NewAvailableStaticPrecompiles`.
156158

app/app.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -504,8 +504,31 @@ func NewChainApp(
504504
logger,
505505
)
506506

507-
// enable sign mode textual by overwriting the default tx config (after setting the bank keeper)
508-
enabledSignModes := append(tx.DefaultSignModes, signingtype.SignMode_SIGN_MODE_TEXTUAL)
507+
// Enabled sign modes, listed explicitly rather than appending to
508+
// tx.DefaultSignModes so that what the chain accepts is stated here rather
509+
// than inherited.
510+
//
511+
// SIGN_MODE_DIRECT_AUX is deliberately excluded (F-2026-18784). The handler
512+
// in cosmossdk.io/x/tx rejects a fee payer who also signs with DIRECT_AUX
513+
// using a raw string compare:
514+
//
515+
// if feePayer == signerData.Address { ... unauthorized ... }
516+
//
517+
// BIP-173 permits an all-uppercase bech32 encoding of the same account, so
518+
// an uppercase Fee.Payer aliasing the victim's lowercase signer address
519+
// fails that check open, while everything downstream decodes both to the
520+
// same AccAddress and deduplicates signers. A sponsor holding a victim's
521+
// DIRECT_AUX signature over a fixed TxBody could then rewrite AuthInfo to
522+
// charge the victim. Still present in our pinned x/tx v0.14.0.
523+
//
524+
// Nothing on Push signs with DIRECT_AUX — the universal client pins
525+
// SIGN_MODE_DIRECT — so enabling it only exposes surface. Restore it once
526+
// x/tx compares decoded bytes (or folds case), not before.
527+
enabledSignModes := []signingtype.SignMode{
528+
signingtype.SignMode_SIGN_MODE_DIRECT,
529+
signingtype.SignMode_SIGN_MODE_LEGACY_AMINO_JSON,
530+
signingtype.SignMode_SIGN_MODE_TEXTUAL,
531+
}
509532
txConfigOpts := tx.ConfigOptions{
510533
EnabledSignModes: enabledSignModes,
511534
TextualCoinMetadataQueryFn: txmodule.NewBankKeeperCoinMetadataQueryFn(app.BankKeeper),

app/sign_modes_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package app
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
signingtype "cosmossdk.io/api/cosmos/tx/signing/v1beta1"
9+
"github.com/cosmos/cosmos-sdk/x/auth/tx"
10+
)
11+
12+
// Regression test for F-2026-18784 (uppercase bech32 fee payer bypasses the
13+
// DIRECT_AUX fee-payer guard).
14+
//
15+
// The handler in cosmossdk.io/x/tx compares the fee payer against the signer
16+
// with a raw string compare:
17+
//
18+
// if feePayer == signerData.Address { ... unauthorized ... }
19+
//
20+
// BIP-173 allows an all-uppercase bech32 encoding of the same account, so an
21+
// uppercase Fee.Payer aliasing the victim's lowercase signer address slips past
22+
// that check while everything downstream decodes both to the same AccAddress.
23+
// Our pinned x/tx v0.14.0 still has the raw compare, so the mode stays off.
24+
//
25+
// This test exists so that a future refactor cannot quietly re-enable DIRECT_AUX
26+
// by going back to appending to tx.DefaultSignModes, which contains it.
27+
func TestEnabledSignModes_ExcludesDirectAux(t *testing.T) {
28+
// setup() constructs the app without InitChain, which is all this needs.
29+
// Setup() is avoided on purpose: it passes the "testing" chain ID and panics
30+
// in the EVM configurator unless another test has already initialised it.
31+
gapp, _ := setup(t, ChainID, false, 0)
32+
modes := gapp.TxConfig().SignModeHandler().SupportedModes()
33+
34+
for _, m := range modes {
35+
require.NotEqual(t, signingtype.SignMode_SIGN_MODE_DIRECT_AUX, m,
36+
"SIGN_MODE_DIRECT_AUX must stay disabled until x/tx compares decoded "+
37+
"bytes rather than raw strings (F-2026-18784)")
38+
}
39+
}
40+
41+
// TestEnabledSignModes_KeepsTheModesWeActuallyUse guards the other direction:
42+
// dropping DIRECT_AUX must not take anything else with it. The universal client
43+
// signs with SIGN_MODE_DIRECT, and TEXTUAL is enabled deliberately (it is not in
44+
// tx.DefaultSignModes and needs the bank keeper).
45+
func TestEnabledSignModes_KeepsTheModesWeActuallyUse(t *testing.T) {
46+
gapp, _ := setup(t, ChainID, false, 0)
47+
modes := gapp.TxConfig().SignModeHandler().SupportedModes()
48+
49+
has := func(want signingtype.SignMode) bool {
50+
for _, m := range modes {
51+
if m == want {
52+
return true
53+
}
54+
}
55+
return false
56+
}
57+
58+
require.True(t, has(signingtype.SignMode_SIGN_MODE_DIRECT), "DIRECT is what the universal client signs with")
59+
require.True(t, has(signingtype.SignMode_SIGN_MODE_LEGACY_AMINO_JSON), "AMINO_JSON is needed for ledger/legacy clients")
60+
require.True(t, has(signingtype.SignMode_SIGN_MODE_TEXTUAL), "TEXTUAL is enabled deliberately")
61+
}
62+
63+
// TestDefaultSignModesStillContainsDirectAux documents why the explicit list
64+
// exists. If upstream ever drops DIRECT_AUX from DefaultSignModes this test
65+
// fails, and the explicit enumeration can be reconsidered.
66+
func TestDefaultSignModesStillContainsDirectAux(t *testing.T) {
67+
found := false
68+
for _, m := range tx.DefaultSignModes {
69+
if m.String() == "SIGN_MODE_DIRECT_AUX" {
70+
found = true
71+
}
72+
}
73+
require.True(t, found,
74+
"tx.DefaultSignModes no longer contains DIRECT_AUX; the explicit list in app.go may no longer be needed")
75+
}

app/txpolicy/gasless.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ func IsGaslessTx(tx sdk.Tx) bool {
3333
for _, msg := range msgs {
3434
switch m := msg.(type) {
3535
case *authz.MsgExec:
36+
// An empty nest would pass the loop below vacuously and make the whole
37+
// tx gasless, bypassing the fee and min-gas-price decorators (F-2026-18816).
38+
if len(m.Msgs) == 0 {
39+
return false
40+
}
3641
// Only gasless if ALL inner messages are allowed
3742
for _, innerMsg := range m.Msgs {
3843
if !slices.Contains(GaslessMsgTypes, innerMsg.TypeUrl) {

app/txpolicy/gasless_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,87 @@ func TestGaslessMsgTypesExcludeEthereumTx(t *testing.T) {
5252
require.True(t, txpolicy.IsGaslessTx(tx))
5353
})
5454
}
55+
56+
// TestIsGaslessTxAuthzExecNesting guards the authz.MsgExec branch of IsGaslessTx.
57+
//
58+
// The inner-message loop is an "all must be allowlisted" check, so an empty nest
59+
// satisfies it vacuously and would make the whole tx gasless - skipping
60+
// DeductFeeDecorator and MinGasPriceDecorator for a zero-fee tx, and handing the
61+
// signer a free on-chain account via AccountInitDecorator, which gates on this
62+
// same predicate. Nothing upstream catches it: authz.MsgExec has no ValidateBasic
63+
// in SDK v0.53.7, and the empty check lives only in the msg server, which runs
64+
// after the fee decorators (F-2026-18816).
65+
func TestIsGaslessTxAuthzExecNesting(t *testing.T) {
66+
anyOf := func(t *testing.T, msg sdk.Msg) *codectypes.Any {
67+
t.Helper()
68+
a, err := codectypes.NewAnyWithValue(msg)
69+
require.NoError(t, err)
70+
return a
71+
}
72+
73+
tests := []struct {
74+
name string
75+
inner []sdk.Msg
76+
gasless bool
77+
reason string
78+
}{
79+
{
80+
name: "empty nest is not gasless",
81+
inner: nil,
82+
gasless: false,
83+
reason: "an empty authz.MsgExec must not pass the inner allowlist loop vacuously",
84+
},
85+
{
86+
name: "empty non-nil nest is not gasless",
87+
inner: []sdk.Msg{},
88+
gasless: false,
89+
reason: "a zero-length (but non-nil) inner message list must be rejected too",
90+
},
91+
{
92+
name: "all-allowlisted nest stays gasless",
93+
inner: []sdk.Msg{&uexecutortypes.MsgVoteInbound{}, &uexecutortypes.MsgVoteOutbound{}},
94+
gasless: true,
95+
reason: "a nest of only allowlisted messages must remain gasless",
96+
},
97+
{
98+
name: "mixed nest is not gasless",
99+
inner: []sdk.Msg{&uexecutortypes.MsgVoteInbound{}, &evmtypes.MsgEthereumTx{}},
100+
gasless: false,
101+
reason: "one non-allowlisted inner message must disqualify the whole tx",
102+
},
103+
{
104+
name: "nested MsgExec is not gasless",
105+
inner: []sdk.Msg{&authz.MsgExec{Msgs: []*codectypes.Any{}}},
106+
gasless: false,
107+
reason: "authz.MsgExec is not itself an allowlisted type, so nesting one must not recurse into a vacuous pass",
108+
},
109+
}
110+
111+
for _, tc := range tests {
112+
t.Run(tc.name, func(t *testing.T) {
113+
// Preserve the nil vs. zero-length distinction: len() treats them the
114+
// same, but constructing both proves the guard does not depend on it.
115+
var inner []*codectypes.Any
116+
if tc.inner != nil {
117+
inner = make([]*codectypes.Any, 0, len(tc.inner))
118+
for _, m := range tc.inner {
119+
inner = append(inner, anyOf(t, m))
120+
}
121+
}
122+
123+
tx := msgsOnlyTx{msgs: []sdk.Msg{&authz.MsgExec{Msgs: inner}}}
124+
require.Equal(t, tc.gasless, txpolicy.IsGaslessTx(tx), tc.reason)
125+
})
126+
}
127+
}
128+
129+
// TestIsGaslessTxEmptyExecAlongsideAllowedMsg pins the multi-message case: the
130+
// outer loop must not let an allowlisted sibling carry an empty nest through.
131+
func TestIsGaslessTxEmptyExecAlongsideAllowedMsg(t *testing.T) {
132+
tx := msgsOnlyTx{msgs: []sdk.Msg{
133+
&uexecutortypes.MsgVoteInbound{},
134+
&authz.MsgExec{},
135+
}}
136+
require.False(t, txpolicy.IsGaslessTx(tx),
137+
"an empty authz.MsgExec must disqualify the tx even next to an allowlisted message")
138+
}

precompiles/usigverifier/README.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,36 @@ interface IUSigVerifier {
4040

4141
| Method | Signed bytes | Gas | Use when |
4242
|---|---|---|---|
43-
| `verifyEd25519(bytes,bytes32,bytes)` | `"0x" + hex(msgDigest)` (66 ASCII bytes) | 4000 | UEA_SVM / Solana-wallet flows where the user signs a hex string in Phantom/Solflare |
44-
| `verifyEd25519RawMessage(bytes,bytes,bytes)` | Raw `message` bytes | 4000 | New integrations / relayers using standard `ed25519.Sign(privKey, rawBytes)` |
43+
| `verifyEd25519(bytes,bytes32,bytes)` | `"0x" + hex(msgDigest)` (66 ASCII bytes) | 4000 (flat) | UEA_SVM / Solana-wallet flows where the user signs a hex string in Phantom/Solflare |
44+
| `verifyEd25519RawMessage(bytes,bytes,bytes)` | Raw `message` bytes | `4000 + 12` per 32-byte word of `message` | New integrations / relayers using standard `ed25519.Sign(privKey, rawBytes)` |
4545

4646
Both methods are `view` and touch no chain state.
4747

48+
### Why only the raw method scales with size
49+
50+
`ed25519.Verify` hashes the whole message, so its CPU cost grows with the message
51+
(~58 µs at 32 B, ~146 µs at 128 KiB, ~922 µs at 1 MB). `verifyEd25519` always verifies
52+
the same 66-byte ASCII string no matter what the caller sends, so its cost is constant
53+
and its price stays flat. `verifyEd25519RawMessage` verifies caller-supplied bytes, so
54+
it is priced per 32-byte word — the same per-word rate the EVM `SHA-256` precompile
55+
charges for comparable hashing work.
56+
57+
Because both methods are `view`, a contract can park one large message in memory and
58+
loop `STATICCALL`s over it, paying the calldata only once. Pricing alone is therefore
59+
not the whole defence: `message` is also **hard-capped at 128 KiB**
60+
(`MaxEd25519MessageBytes`), and anything larger reverts with `message too large`
61+
instead of being verified.
62+
63+
| `len(message)` | Gas |
64+
|---|---|
65+
| 0 | 4,000 |
66+
| 32 B | 4,012 |
67+
| 1 KiB | 4,384 |
68+
| 8 KiB | 7,072 |
69+
| 64 KiB | 28,576 |
70+
| 128 KiB (cap) | 53,152 |
71+
| > 128 KiB | reverts |
72+
4873
## Verification Semantics
4974

5075
Two methods, two distinct signing conventions. **A signature produced for one method will not verify under the other** — the test vectors in `query_test.go` lock this in.
@@ -69,13 +94,14 @@ Standard Ed25519 verification — signature is checked against the raw `message`
6994
ok = ed25519.Verify(pubKeyBytes, message, signature)
7095
```
7196

72-
Use this when your signer uses `ed25519.Sign(privKey, rawBytes)` (default in every Solana SDK / nacl library). `message` may be any length, not just 32 bytes.
97+
Use this when your signer uses `ed25519.Sign(privKey, rawBytes)` (default in every Solana SDK / nacl library). `message` may be any length up to `MaxEd25519MessageBytes` (128 KiB), not just 32 bytes.
7398

7499
### Common rules
75100

76101
- `pubKey` must be exactly 32 bytes; `signature` must be exactly 64 bytes — otherwise the precompile reverts with `invalid params`.
102+
- `verifyEd25519RawMessage` reverts with `message too large` past `MaxEd25519MessageBytes` (128 KiB).
77103
- Unknown method IDs revert with the standard `unknown method` error.
78-
- Both methods cost `4000` gas.
104+
- `verifyEd25519` costs a flat `4000` gas; `verifyEd25519RawMessage` costs `4000` plus `12` per 32-byte word of `message`.
79105

80106
## Generating the ABI
81107

@@ -120,7 +146,8 @@ If the call returns `0x` (empty), the precompile is not in `active_static_precom
120146
precompiles/usigverifier/
121147
|-- USigVerifier.sol Solidity interface (the source of truth for the ABI)
122148
|-- abi.json Embedded into the binary via go:embed
123-
|-- usigverifier.go Precompile struct, NewPrecompile / NewPrecompileV2, RequiredGas, Run
124-
|-- query.go VerifyEd25519 method handler
149+
|-- usigverifier.go Precompile struct, NewPrecompile / NewPrecompileV2, RequiredGas (gas schedule), Run
150+
|-- query.go VerifyEd25519 / VerifyEd25519RawMessage method handlers
151+
|-- gas_test.go Gas-schedule + size-cap regression tests and benchmarks
125152
+-- README.md (this file)
126153
```

0 commit comments

Comments
 (0)