Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ba765f2
fix(erpc:PLA-1354): validate postgres table identifiers
0x666c6f May 6, 2026
7ac5607
chore(erpc:PLA-1353): bump sonic to 1.15.1
0x666c6f May 6, 2026
167dcc4
chore(erpc): regenerate TS config types for X402 strategy
0x666c6f May 6, 2026
5985319
docs(erpc:PLA-1353): address pgx audit comments
0x666c6f May 6, 2026
3a65153
fix(erpc:PLA-1354): allow postgres identifier chars
0x666c6f May 6, 2026
d9fdcaa
Merge pull request #68 from morpho-org/feature/pla-1394-regenerate-er…
0x666c6f May 6, 2026
b3f1e9c
ci(security:PLA-1349): harden erpc workflows
0x666c6f May 6, 2026
3df8d08
fix(erpc:PLA-1349): align version tag validation
0x666c6f May 6, 2026
d608599
ci: publish erpc images to ECR
prd-carapulse[bot] May 6, 2026
7f37379
ci: allow carapulse review and ECR publish
prd-carapulse[bot] May 6, 2026
b9a25eb
ci: read ECR publish config from repo variables
0x666c6f May 6, 2026
acdfc87
Merge pull request #70 from morpho-org/hermes/pla-1398-erpc-ecr-publish
0x666c6f May 6, 2026
a16a70c
Merge pull request #69 from morpho-org/feature/pla-1349-ciworkflow-su…
0x666c6f May 6, 2026
e84245c
Merge pull request #67 from morpho-org/feature/pla-1353-erpc-bump-son…
0x666c6f May 6, 2026
1aa25e2
Merge pull request #66 from morpho-org/feature/pla-1354-erpc-validate…
0x666c6f May 6, 2026
1fd0bb6
fix(release:PLA-1398): unblock docker publish jobs
0x666c6f May 7, 2026
defacbf
Merge pull request #71 from morpho-org/hermes/PLA-1398-fix-release-st…
0x666c6f May 7, 2026
b585b28
chore: release 0.0.80
invalid-email-address May 7, 2026
bdb800b
Merge pull request #72 from morpho-org/release/0.0.80
0x666c6f May 7, 2026
7d4100f
feat: add all-upstreams diagnostic mode
prd-carapulse[bot] May 7, 2026
069df42
fix(check-all-upstreams): address review findings
0x666c6f May 7, 2026
3cb8a01
Merge pull request #73 from morpho-org/hermes/check-all-upstreams
0x666c6f May 7, 2026
24d38e7
chore: release 0.0.81
invalid-email-address May 7, 2026
89e5f76
Merge pull request #74 from morpho-org/release/0.0.81
0x666c6f May 7, 2026
efc3b5e
chore: merge morpho-main into PR #65
dev-carapulse[bot] May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
# - "src/**/*.js"
# - "src/**/*.jsx"

permissions: {}

jobs:
claude-review:
if: github.event.pull_request.head.repo.full_name == github.repository
Expand Down Expand Up @@ -60,6 +62,7 @@ jobs:
uses: anthropics/claude-code-action@2cc1ac1331eac7a6a96d716dd204dd2888d0fcd2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'prd-carapulse'
claude_args: "--model claude-opus-4-6"
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
pull_request_review:
types: [submitted]

permissions: {}

jobs:
claude:
if: |
Expand Down
268 changes: 230 additions & 38 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions .github/workflows/scorecards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ on:
push:
branches: ["main"]

# Declare default permissions as read only.
permissions: read-all
permissions: {}

jobs:
analysis:
Expand Down
7 changes: 7 additions & 0 deletions architecture/evm/eth_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func projectPreForward_eth_call(ctx context.Context, network common.Network, nq
return false, nil, nil
}

// Diagnostic mode: skip eth_call-specific pre-forward (multicall3 batching,
// param normalization, cache pre-check) so the request reaches Network.Forward
// unwrapped and each upstream is probed with the original payload.
if nq.ShouldCheckAllUpstreams() {
return false, nil, nil
}

// Normalize params: ensure block param is present
jrq.RLock()
paramsLen := len(jrq.Params)
Expand Down
44 changes: 44 additions & 0 deletions common/postgresql_identifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package common

import (
"fmt"
"strings"
"unicode"
)

// ValidatePostgreSQLTableIdentifier rejects table identifiers that cannot be
// safely interpolated after pgx identifier quoting.
func ValidatePostgreSQLTableIdentifier(name string) error {
_, err := PostgreSQLTableIdentifierParts(name)
return err
}

// PostgreSQLTableIdentifierParts returns one table component or schema/table
// components after validating each component as an unquoted PostgreSQL identifier.
func PostgreSQLTableIdentifierParts(name string) ([]string, error) {
parts := strings.Split(name, ".")
if len(parts) > 2 {
return nil, fmt.Errorf("postgres table identifier %q: too many qualifying parts", name)
}
for _, part := range parts {
if !isPostgreSQLIdentifierComponent(part) {
return nil, fmt.Errorf("postgres table identifier %q: invalid component %q", name, part)
}
}
return parts, nil
}

func isPostgreSQLIdentifierComponent(name string) bool {
for i, r := range name {
if i == 0 {
if r != '_' && !unicode.IsLetter(r) {
return false
}
continue
}
if r != '_' && r != '$' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return false
}
}
return name != ""
}
54 changes: 54 additions & 0 deletions common/postgresql_identifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package common

import (
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestPostgreSQLConnectorConfigValidateTableIdentifier(t *testing.T) {
baseConfig := func(table string) *PostgreSQLConnectorConfig {
return &PostgreSQLConnectorConfig{
Table: table,
ConnectionUri: "postgres://user:pass@localhost/db?sslmode=disable",
InitTimeout: Duration(time.Second),
GetTimeout: Duration(time.Second),
SetTimeout: Duration(time.Second),
MinConns: 1,
MaxConns: 2,
}
}

for _, table := range []string{
"erpc_json_rpc_cache",
"myschema.cache",
"_schema.table_1",
"CamelCase",
"schema.cache$2026",
"\u00e9rpc_cache",
} {
t.Run("valid_"+table, func(t *testing.T) {
require.NoError(t, baseConfig(table).Validate())
})
}

for _, table := range []string{
"; DROP TABLE foo; --",
"schema.table.extra",
"bad-name",
"1table",
".table",
"schema.",
"schema.*",
"$table",
"schema.$cache",
"table name",
} {
t.Run("invalid_"+table, func(t *testing.T) {
err := baseConfig(table).Validate()
require.Error(t, err)
require.ErrorContains(t, err, "database.*.connector.postgresql.table is invalid")
})
}
}
27 changes: 27 additions & 0 deletions common/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const (
headerDirectiveSkipCacheRead = "X-ERPC-Skip-Cache-Read"
headerDirectiveCacheMaxAge = "X-ERPC-Cache-Max-Age"
headerDirectiveUseUpstream = "X-ERPC-Use-Upstream"
headerDirectiveCheckAllUpstreams = "X-ERPC-Check-All-Upstreams"
headerDirectiveSkipInterpolation = "X-ERPC-Skip-Interpolation"
headerDirectiveEnforceHighestBlock = "X-ERPC-Enforce-Highest-Block"
headerDirectiveEnforceGetLogsRange = "X-ERPC-Enforce-GetLogs-Range"
Expand All @@ -70,6 +71,7 @@ const (
queryDirectiveSkipCacheRead = "skip-cache-read"
queryDirectiveCacheMaxAge = "cache-max-age"
queryDirectiveUseUpstream = "use-upstream"
queryDirectiveCheckAllUpstreams = "check-all-upstreams"
queryDirectiveSkipInterpolation = "skip-interpolation"
queryDirectiveEnforceHighestBlock = "enforce-highest-block"
queryDirectiveEnforceGetLogsRange = "enforce-getlogs-range"
Expand All @@ -96,6 +98,7 @@ var directiveKeyRegistry = []directiveKeyNames{
{header: headerDirectiveSkipCacheRead, query: queryDirectiveSkipCacheRead},
{header: headerDirectiveCacheMaxAge, query: queryDirectiveCacheMaxAge},
{header: headerDirectiveUseUpstream, query: queryDirectiveUseUpstream},
{header: headerDirectiveCheckAllUpstreams, query: queryDirectiveCheckAllUpstreams},
{header: headerDirectiveSkipInterpolation, query: queryDirectiveSkipInterpolation},
{header: headerDirectiveEnforceHighestBlock, query: queryDirectiveEnforceHighestBlock},
{header: headerDirectiveEnforceGetLogsRange, query: queryDirectiveEnforceGetLogsRange},
Expand Down Expand Up @@ -151,6 +154,16 @@ type RequestDirectives struct {
// For example "alchemy" or "my-own-*", etc.
UseUpstream string `json:"useUpstream,omitempty"`

// CheckAllUpstreams instructs the network to execute this request against
// every selected upstream and return a diagnostic result instead of the
// first usable upstream response. It is intended for probing provider-specific
// limits such as calldata, returndata, and gas limits.
//
// Setting this also bypasses request multiplexing and cache reads/writes
// for the originating request, and disables eth_call multicall3 batching,
// so each upstream is probed with the original payload.
CheckAllUpstreams bool `json:"checkAllUpstreams,omitempty"`

// Instruct the proxy to bypass method exclusion checks.
ByPassMethodExclusion bool `json:"-"`

Expand Down Expand Up @@ -247,6 +260,7 @@ func (d *RequestDirectives) Clone() *RequestDirectives {
SkipCacheRead: d.SkipCacheRead,
CacheMaxAgeSeconds: nil,
UseUpstream: d.UseUpstream,
CheckAllUpstreams: d.CheckAllUpstreams,
ByPassMethodExclusion: d.ByPassMethodExclusion,
SkipInterpolation: d.SkipInterpolation,
EnforceHighestBlock: d.EnforceHighestBlock,
Expand Down Expand Up @@ -733,6 +747,9 @@ func (r *NormalizedRequest) EnrichFromHttp(headers http.Header, queryArgs url.Va
if hv := headers.Get(headerDirectiveUseUpstream); hv != "" {
r.directives.UseUpstream = hv
}
if hv := headers.Get(headerDirectiveCheckAllUpstreams); hv != "" {
r.directives.CheckAllUpstreams = strings.ToLower(strings.TrimSpace(hv)) == "true"
}
if hv := headers.Get(headerDirectiveSkipInterpolation); hv != "" {
r.directives.SkipInterpolation = strings.ToLower(strings.TrimSpace(hv)) == "true"
}
Expand Down Expand Up @@ -801,6 +818,9 @@ func (r *NormalizedRequest) EnrichFromHttp(headers http.Header, queryArgs url.Va
if useUpstream := queryArgs.Get(queryDirectiveUseUpstream); useUpstream != "" {
r.directives.UseUpstream = strings.TrimSpace(useUpstream)
}
if checkAllUpstreams := queryArgs.Get(queryDirectiveCheckAllUpstreams); checkAllUpstreams != "" {
r.directives.CheckAllUpstreams = strings.ToLower(strings.TrimSpace(checkAllUpstreams)) == "true"
}

if retryEmpty := queryArgs.Get(queryDirectiveRetryEmpty); retryEmpty != "" {
r.directives.RetryEmpty = strings.ToLower(strings.TrimSpace(retryEmpty)) == "true"
Expand Down Expand Up @@ -924,6 +944,13 @@ func (r *NormalizedRequest) CacheMaxAgeExplicit() bool {
return r.directives.CacheMaxAgeExplicit
}

func (r *NormalizedRequest) ShouldCheckAllUpstreams() bool {
if r == nil || r.directives == nil {
return false
}
return r.directives.CheckAllUpstreams
}

func (r *NormalizedRequest) Directives() *RequestDirectives {
if r == nil {
return nil
Expand Down
67 changes: 67 additions & 0 deletions common/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,73 @@ func TestEnrichFromHttp_CacheMaxAgeDirective(t *testing.T) {
})
}

func TestEnrichFromHttp_CheckAllUpstreamsDirective(t *testing.T) {
t.Run("header_sets_directive", func(t *testing.T) {
req := NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_call"}`))
headers := http.Header{}
headers.Set("X-ERPC-Check-All-Upstreams", "true")

req.EnrichFromHttp(headers, nil, UserAgentTrackingModeSimplified)

if dir := req.Directives(); dir == nil || !dir.CheckAllUpstreams {
t.Fatalf("expected CheckAllUpstreams=true after header directive")
}
})

t.Run("query_sets_directive", func(t *testing.T) {
req := NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_call"}`))
query := url.Values{}
query.Set("check-all-upstreams", "true")

req.EnrichFromHttp(nil, query, UserAgentTrackingModeSimplified)

if dir := req.Directives(); dir == nil || !dir.CheckAllUpstreams {
t.Fatalf("expected CheckAllUpstreams=true after query directive")
}
})

t.Run("parsing_edge_cases", func(t *testing.T) {
cases := []struct {
value string
enabled bool
}{
{"true", true},
{"TRUE", true},
{" true ", true},
{"false", false},
{"FALSE", false},
{"1", false}, // only literal "true" (case-insensitive) enables
{"yes", false},
{"", false}, // empty value should leave directive unchanged
}
for _, tc := range cases {
t.Run("header="+tc.value, func(t *testing.T) {
req := NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_call"}`))
headers := http.Header{}
if tc.value != "" {
headers.Set("X-ERPC-Check-All-Upstreams", tc.value)
}
req.EnrichFromHttp(headers, nil, UserAgentTrackingModeSimplified)
if got := req.Directives() != nil && req.Directives().CheckAllUpstreams; got != tc.enabled {
t.Fatalf("value=%q: expected CheckAllUpstreams=%v, got %v", tc.value, tc.enabled, got)
}
})
}
})
}

func TestShouldCheckAllUpstreams_NilSafety(t *testing.T) {
var nilReq *NormalizedRequest
if nilReq.ShouldCheckAllUpstreams() {
t.Fatalf("expected false from nil receiver")
}

req := NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_call"}`))
if req.ShouldCheckAllUpstreams() {
t.Fatalf("expected false from request with no directives set")
}
}

// TestHeaderOverridesConfigDefault_ValidateTransactionsRoot verifies that when the
// config defaults set ValidateTransactionsRoot=true, a header/query-string can
// override it to false.
Expand Down
3 changes: 3 additions & 0 deletions common/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,9 @@ func (p *PostgreSQLConnectorConfig) Validate() error {
if p.Table == "" {
return fmt.Errorf("database.*.connector.postgresql.table is required")
}
if err := ValidatePostgreSQLTableIdentifier(p.Table); err != nil {
return fmt.Errorf("database.*.connector.postgresql.table is invalid: %w", err)
}
if p.MinConns == 0 {
return fmt.Errorf("database.*.connector.postgresql.minConns is required")
}
Expand Down
Loading