From aa5c6ef8520badec613001d9d1233cfeaf48566e Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sun, 23 Aug 2026 15:46:52 +0300 Subject: [PATCH] fix(walletconnect): wire the projectId gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _require_project_id shipped in lib.star but no handler called it — every route answered with no credential at all, unlike the real WC relay. All seven handlers now demand a projectId (body, query, or bearer); the engine test and VM suite send one, and the suite's gate probes assert the 401s. The sidecar deviation narrows to what remains true: any non-empty projectId passes (no registry check). --- CONFORMANCE.md | 18 ++++++++-------- .../walletconnect-style/scripts/relay.star | 21 +++++++++++++++++++ adapters/walletconnect_style_test.go | 20 ++++++++++-------- conformance/matrix.json | 21 +++++++++++++++++-- conformance/matrix.yaml | 2 +- internal/engine/walletconnect_style_test.go | 3 +++ 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index aa69f853..7e834b5b 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -1683,7 +1683,7 @@ Named by their `// =====` section markers. **walletconnect-style** -- every route answers without a projectId (the gate is not wired) +- every route demands a projectId — body, query, or bearer all pass - a wc: URI pairing round-trips its topic, relay protocol, and symKey - an auto pairing mints a fresh topic and a 64-hex symKey - proposing requires pairingTopic — and accepts one never paired @@ -9029,19 +9029,19 @@ behavior notes live in each adapter's README. - Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device) - personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed -- The projectId gate ships but is never wired — every route answers without a credential +- Any non-empty projectId passes the gate — no validation against a project registry - Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing - Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)
Derived behavior tags (static — from scripts/*.star, not SDK-verified) -- `POST` `/v1/pairings` — body, stateful, errors -- `POST` `/v1/sessions` — body, stateful, errors -- `GET` `/v1/sessions` — query, stateful, paginate, errors -- `POST` `/v1/sessions/{topic}/approve` — params, stateful, errors -- `POST` `/v1/sessions/{topic}/request` — body, params, stateful, errors -- `POST` `/v1/sessions/{topic}/extend` — params, stateful, errors -- `DELETE` `/v1/sessions/{topic}` — params, stateful, errors +- `POST` `/v1/pairings` — body, query, auth, stateful, errors +- `POST` `/v1/sessions` — body, query, auth, stateful, errors +- `GET` `/v1/sessions` — body, query, auth, stateful, paginate, errors +- `POST` `/v1/sessions/{topic}/approve` — body, query, params, auth, stateful, errors +- `POST` `/v1/sessions/{topic}/request` — body, query, params, auth, stateful, errors +- `POST` `/v1/sessions/{topic}/extend` — body, query, params, auth, stateful, errors +- `DELETE` `/v1/sessions/{topic}` — body, query, params, auth, stateful, errors
diff --git a/adapters/walletconnect-style/scripts/relay.star b/adapters/walletconnect-style/scripts/relay.star index 970957ec..f351c93b 100644 --- a/adapters/walletconnect-style/scripts/relay.star +++ b/adapters/walletconnect-style/scripts/relay.star @@ -21,6 +21,9 @@ # on_create_pairing establishes a pairing from either a wc: URI or an # explicit topic. Returns the pairing object. def on_create_pairing(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + body = req.get("body") if body == None: body = {} @@ -61,6 +64,9 @@ def on_create_pairing(req): # on_propose_session proposes a new session from a pairing. def on_propose_session(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + body = req.get("body") if body == None: body = {} @@ -102,6 +108,9 @@ def on_propose_session(req): # field for a continuation cursor, so cursor-based paging is not surfaced here; # `limit` still caps the page size. def on_list_sessions(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + sc = store_collection("sessions") result = [] for s in sc.list(): @@ -114,6 +123,9 @@ def on_list_sessions(req): # on_approve_session acknowledges (approves) a session, simulating the # wallet's approval response. def on_approve_session(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + topic = req["params"]["topic"] sc = store_collection("sessions") @@ -142,6 +154,9 @@ def on_approve_session(req): # on_session_request handles a wallet JSON-RPC request (e.g. # eth_requestAccounts, personal_sign, eth_sendTransaction). def on_session_request(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + topic = req["params"]["topic"] sc = store_collection("sessions") @@ -191,6 +206,9 @@ def on_session_request(req): # on_extend_session refreshes the session expiry. def on_extend_session(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + topic = req["params"]["topic"] sc = store_collection("sessions") @@ -205,6 +223,9 @@ def on_extend_session(req): # on_disconnect_session disconnects (deletes) a session. def on_disconnect_session(req): + if _require_project_id(req) == None: + return respond(401, {"error": "unauthorized", "message": "a valid projectId is required (body, query, or bearer)"}) + topic = req["params"]["topic"] sc = store_collection("sessions") diff --git a/adapters/walletconnect_style_test.go b/adapters/walletconnect_style_test.go index 9f050d46..756dfa57 100644 --- a/adapters/walletconnect_style_test.go +++ b/adapters/walletconnect_style_test.go @@ -76,6 +76,9 @@ func newWalletconnectFixture(t *testing.T, start time.Time) *walletconnectFixtur } func (f *walletconnectFixture) call(handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + if auth == "" { + auth = "Bearer wc-vm-test-project" + } f.t.Helper() headers := map[string]string{} if auth != "" { @@ -156,18 +159,17 @@ func wcHash(s string) bool { func TestWalletconnectPairingAndSessionLifecycle(t *testing.T) { f := newWalletconnectFixture(t, wcBase()) - // ===== every route answers without a projectId (the gate is not wired) ===== - // The manifest declares identity.token_scheme: bearer and lib.star ships - // _require_project_id, but no handler calls the helper — the relay answers - // with no credential at all. Asserted as-is; see the deviation report. - if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, ""); r.Status != 200 { - t.Fatalf("pairing with no projectId -> %d, want 200 (gate unenforced): %v", r.Status, r.Body) + // ===== every route demands a projectId — body, query, or bearer all pass ===== + // The relay gates like the real WC service: a projectId must ride the + // body (most SDKs), the query, or the Authorization bearer. + if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "none"); r.Status != 401 { + t.Fatalf("pairing with no projectId -> %d, want 401: %v", r.Status, r.Body) } if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "Bearer not-a-project-id"); r.Status != 200 { - t.Fatalf("pairing with a bogus bearer -> %d, want 200 (gate unenforced): %v", r.Status, r.Body) + t.Fatalf("pairing with a bearer projectId -> %d, want 200: %v", r.Status, r.Body) } - if r := f.call("on_list_sessions", "GET", "/v1/sessions", nil, nil, nil, ""); r.Status != 200 { - t.Fatalf("list with no projectId -> %d, want 200 (gate unenforced)", r.Status) + if r := f.call("on_list_sessions", "GET", "/v1/sessions", nil, nil, nil, "none"); r.Status != 401 { + t.Fatalf("list with no projectId -> %d, want 401", r.Status) } // ===== a wc: URI pairing round-trips its topic, relay protocol, and symKey ===== diff --git a/conformance/matrix.json b/conformance/matrix.json index 864f90a8..87e1d4fb 100644 --- a/conformance/matrix.json +++ b/conformance/matrix.json @@ -206096,7 +206096,7 @@ "sdks": [], "behaviors": [], "vm_behaviors": [ - "every route answers without a projectId (the gate is not wired)", + "every route demands a projectId — body, query, or bearer all pass", "a wc: URI pairing round-trips its topic, relay protocol, and symKey", "an auto pairing mints a fresh topic and a 64-hex symKey", "proposing requires pairingTopic — and accepts one never paired", @@ -206116,7 +206116,7 @@ "deviations": [ "Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)", "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed", - "The projectId gate ships but is never wired — every route answers without a credential", + "Any non-empty projectId passes the gate — no validation against a project registry", "Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing", "Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)" ], @@ -206156,6 +206156,8 @@ "route": "/v1/pairings", "tags": [ "body", + "query", + "auth", "stateful", "errors" ] @@ -206165,6 +206167,8 @@ "route": "/v1/sessions", "tags": [ "body", + "query", + "auth", "stateful", "errors" ] @@ -206173,7 +206177,9 @@ "method": "GET", "route": "/v1/sessions", "tags": [ + "body", "query", + "auth", "stateful", "paginate", "errors" @@ -206183,7 +206189,10 @@ "method": "POST", "route": "/v1/sessions/{topic}/approve", "tags": [ + "body", + "query", "params", + "auth", "stateful", "errors" ] @@ -206193,7 +206202,9 @@ "route": "/v1/sessions/{topic}/request", "tags": [ "body", + "query", "params", + "auth", "stateful", "errors" ] @@ -206202,7 +206213,10 @@ "method": "POST", "route": "/v1/sessions/{topic}/extend", "tags": [ + "body", + "query", "params", + "auth", "stateful", "errors" ] @@ -206211,7 +206225,10 @@ "method": "DELETE", "route": "/v1/sessions/{topic}", "tags": [ + "body", + "query", "params", + "auth", "stateful", "errors" ] diff --git a/conformance/matrix.yaml b/conformance/matrix.yaml index 15e23896..66eb3efa 100644 --- a/conformance/matrix.yaml +++ b/conformance/matrix.yaml @@ -1093,7 +1093,7 @@ adapters: deviations: - "Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)" - "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed" - - "The projectId gate ships but is never wired — every route answers without a credential" + - "Any non-empty projectId passes the gate — no validation against a project registry" - "Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing" - "Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)" missing: diff --git a/internal/engine/walletconnect_style_test.go b/internal/engine/walletconnect_style_test.go index 22a5746b..bbd59c86 100644 --- a/internal/engine/walletconnect_style_test.go +++ b/internal/engine/walletconnect_style_test.go @@ -276,6 +276,7 @@ func wcPost(t *testing.T, base, path string, bodyObj map[string]any) (string, in t.Fatal(err) } req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer wc-engine-test-project") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) @@ -288,6 +289,7 @@ func wcPost(t *testing.T, base, path string, bodyObj map[string]any) (string, in func wcGet(t *testing.T, base, path string) (string, int) { t.Helper() req, err := http.NewRequest("GET", base+path, nil) + req.Header.Set("Authorization", "Bearer wc-engine-test-project") if err != nil { t.Fatal(err) } @@ -303,6 +305,7 @@ func wcGet(t *testing.T, base, path string) (string, int) { func wcDelete(t *testing.T, base, path string) (string, int) { t.Helper() req, err := http.NewRequest("DELETE", base+path, nil) + req.Header.Set("Authorization", "Bearer wc-engine-test-project") if err != nil { t.Fatal(err) }