Skip to content

Commit f028e59

Browse files
abernatskiyclaude
andcommitted
feat(portal): opt-in PORTAL_ALLOW_MISSING_ACCESS_LIST for RPCs that omit accessList
The fork's hardened RPC-path accessList-required check (in standardizeTransactions, added by every wiring patch) crashes any app whose RPC/proxy omits the spec-required accessList on typed-envelope txs. In practice this hits SQD's own zkSync RPC proxy, which returns type-0x2 txs with no accessList key at all (verified 11/11 across 50 blocks) — a fork-introduced regression vs stock ponder on a supported chain. The guard is right in general (it prevents fabricating an empty/NULL access list over a real one, #27/#32), so keep it as the default. Add an opt-in knob (default OFF, matching the PORTAL_* boolean convention): - unset → unchanged loud RpcProviderError, whose meta now NAMES the knob; - set → the missing value flows through and stores as an honest NULL (as the Portal path already does for column-less datasets, #110/#111). Read once per standardizeTransactions call (hoisted above the tx loop — cheap, and toggleable per call in tests). Applied to all 5 tracked wiring patches (0.15.17, 0.16.6-0.16.9), regenerated per PUBLISHING.md. Adds 3 tests to realtime-standardize covering: default-throws-and-names-knob, knob-passes-through-to-NULL on 0x1/2/3/4, and knob-preserves-a-present-list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h8zdhxsC8A2bcFqZRcjeB
1 parent 5a2fe88 commit f028e59

6 files changed

Lines changed: 203 additions & 20 deletions

File tree

portal/realtime-standardize.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,86 @@ test('type-0x2 with accessList ([] and non-empty) passes through unchanged and e
117117
encodeTransaction({ transaction: outFull[0]!, chainId: 1 }).accessList,
118118
).toBe(JSON.stringify(list));
119119
});
120+
121+
// ─────────────────────── (f) opt-in: PORTAL_ALLOW_MISSING_ACCESS_LIST ───────────────────────
122+
// The default guard is right for a compliant provider, but SQD's own zkSync RPC proxy returns
123+
// EIP-1559 (0x2) txs with NO `accessList` key at all — so on that documented-supported endpoint
124+
// the guard crashes the whole app. `PORTAL_ALLOW_MISSING_ACCESS_LIST` (default OFF) lets an
125+
// operator who knows the omission is legitimate proceed and store the honest NULL, exactly as the
126+
// Portal path already does for datasets that lack the column entirely (#27, #110/#111).
127+
128+
// Run `body` with the knob forced to `value`, restoring the prior env afterwards (no cross-test leak).
129+
const withKnob = (value: string | undefined, body: () => void): void => {
130+
const prev = process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST;
131+
if (value === undefined) {
132+
delete process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST;
133+
} else {
134+
process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST = value;
135+
}
136+
137+
try {
138+
body();
139+
} finally {
140+
if (prev === undefined) {
141+
delete process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST;
142+
} else {
143+
process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST = prev;
144+
}
145+
}
146+
};
147+
148+
// The loud default error must NAME the escape hatch — an operator hitting the zkSync crash needs to
149+
// be told the exact knob, not left to grep the source. The guidance rides on `RpcProviderError.meta`
150+
// (surfaced by the logger, like every other error here), not the terse message line.
151+
test('default (knob unset) → missing accessList still throws, and the guidance names the knob', () => {
152+
withKnob(undefined, () => {
153+
const t = tx({ type: '0x2' });
154+
expect(() => standardize(t)).toThrowError(/transaction\.accessList/);
155+
156+
let meta: string[] = [];
157+
try {
158+
standardize(t);
159+
} catch (err) {
160+
meta = (err as { meta?: string[] }).meta ?? [];
161+
}
162+
163+
expect(meta.join(' ')).toMatch(/PORTAL_ALLOW_MISSING_ACCESS_LIST/);
164+
});
165+
});
166+
167+
// With the knob set, EVERY typed envelope missing accessList flows through instead of throwing, and
168+
// the absent value stays `undefined` → encodeTransaction maps it to a NULL (honest absence, not []).
169+
test('knob set → missing accessList on 0x1/0x2/0x3/0x4 passes through, encodes to NULL', () => {
170+
withKnob('1', () => {
171+
for (const type of ['0x1', '0x2', '0x3', '0x4']) {
172+
const t = tx({ type });
173+
expect(() => standardize(t)).not.toThrow();
174+
const out = standardize(t);
175+
expect(out[0]!.accessList).toBeUndefined();
176+
expect(
177+
encodeTransaction({ transaction: out[0]!, chainId: 1 }).accessList,
178+
).toBeNull();
179+
}
180+
});
181+
});
182+
183+
// The knob is an escape hatch for ABSENT lists only — it must never fabricate or drop a REAL one:
184+
// a present access list still round-trips unchanged when the knob is on.
185+
test('knob set → a present accessList is still preserved unchanged', () => {
186+
withKnob('1', () => {
187+
const list = [
188+
{
189+
address: '0x000000000000000000000000000000000000dead',
190+
storageKeys: [
191+
'0x0000000000000000000000000000000000000000000000000000000000000001',
192+
],
193+
},
194+
];
195+
const t = tx({ type: '0x2', accessList: list });
196+
const out = standardize(t);
197+
expect(out[0]!.accessList).toEqual(list);
198+
expect(
199+
encodeTransaction({ transaction: out[0]!, chainId: 1 }).accessList,
200+
).toBe(JSON.stringify(list));
201+
});
202+
});

portal/wiring/0.15.17.patch

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ index acea95b..c502c66 100644
4141
pollingInterval: number;
4242
finalityBlockCount: number;
4343
diff --git a/packages/core/src/rpc/actions.ts b/packages/core/src/rpc/actions.ts
44-
index 32ba7a8..0dabd74 100644
44+
index 32ba7a8..c443cb6 100644
4545
--- a/packages/core/src/rpc/actions.ts
4646
+++ b/packages/core/src/rpc/actions.ts
4747
@@ -1,6 +1,8 @@
@@ -192,7 +192,22 @@ index 32ba7a8..0dabd74 100644
192192
/**
193193
* Helper function for "eth_getTransactionReceipt" request.
194194
*/
195-
@@ -893,6 +1017,33 @@ export const standardizeTransactions = (
195+
@@ -787,6 +911,14 @@ export const standardizeTransactions = (
196+
{ method: "eth_getBlockByNumber" | "eth_getBlockByHash" }
197+
>,
198+
): SyncTransaction[] => {
199+
+ // PORTAL_ALLOW_MISSING_ACCESS_LIST — opt-in to consuming from a non-standard RPC provider (e.g.
200+
+ // SQD's zkSync proxy) that omits the spec-required `accessList` on typed-envelope txs. Default
201+
+ // OFF: a missing accessList is a loud RpcProviderError (below). When set, the missing value is
202+
+ // allowed through and stored as an honest NULL. Read once per call — cheap, hoisted above the
203+
+ // loop — so ops can flip it via env without a rebuild, and tests can toggle it per call. (issue #27)
204+
+ const allowMissingAccessList = Boolean(
205+
+ process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST,
206+
+ );
207+
const transactionIds = new Set<Hex>();
208+
209+
for (const transaction of transactions) {
210+
@@ -893,6 +1025,38 @@ export const standardizeTransactions = (
196211
transaction.gas = "0x0";
197212
}
198213

@@ -203,9 +218,12 @@ index 32ba7a8..0dabd74 100644
203218
+ // defaulting to `[]`, which would fabricate an empty list over a real one. Legacy (0x0), OP
204219
+ // deposit (0x7e), and unknown/system envelopes (e.g. Arbitrum internals) carry no access list
205220
+ // and are left untouched. An explicit `accessList: null` is treated the same as an omitted key
206-
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud.
221+
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud —
222+
+ // UNLESS the operator opts in via PORTAL_ALLOW_MISSING_ACCESS_LIST (see above), in which case
223+
+ // the missing value flows through to that honest NULL by design.
207224
+ const txType: string | undefined = transaction.type;
208225
+ if (
226+
+ !allowMissingAccessList &&
209227
+ (txType === "0x1" ||
210228
+ txType === "0x2" ||
211229
+ txType === "0x3" ||
@@ -216,7 +234,9 @@ index 32ba7a8..0dabd74 100644
216234
+ `Invalid RPC response: 'transaction.accessList' is a required property on type ${txType} transactions`,
217235
+ );
218236
+ error.meta = [
219-
+ "Please report this error to the RPC operator.",
237+
+ "This RPC provider omits the spec-required 'accessList' on typed transactions. If that is " +
238+
+ "expected for this endpoint (e.g. SQD's zkSync proxy), set PORTAL_ALLOW_MISSING_ACCESS_LIST=1 " +
239+
+ "to store the missing access list as NULL and continue; otherwise report this to the RPC operator.",
220240
+ requestText(request),
221241
+ ];
222242
+ error.stack = undefined;

portal/wiring/0.16.6.patch

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ index acea95b..c502c66 100644
4141
pollingInterval: number;
4242
finalityBlockCount: number;
4343
diff --git a/packages/core/src/rpc/actions.ts b/packages/core/src/rpc/actions.ts
44-
index 64bbcbe..bb3d6ad 100644
44+
index 64bbcbe..25c301c 100644
4545
--- a/packages/core/src/rpc/actions.ts
4646
+++ b/packages/core/src/rpc/actions.ts
4747
@@ -1,6 +1,8 @@
@@ -192,7 +192,22 @@ index 64bbcbe..bb3d6ad 100644
192192
/**
193193
* Helper function for "eth_getTransactionReceipt" request.
194194
*/
195-
@@ -893,6 +1017,33 @@ export const standardizeTransactions = (
195+
@@ -787,6 +911,14 @@ export const standardizeTransactions = (
196+
{ method: "eth_getBlockByNumber" | "eth_getBlockByHash" }
197+
>,
198+
): SyncTransaction[] => {
199+
+ // PORTAL_ALLOW_MISSING_ACCESS_LIST — opt-in to consuming from a non-standard RPC provider (e.g.
200+
+ // SQD's zkSync proxy) that omits the spec-required `accessList` on typed-envelope txs. Default
201+
+ // OFF: a missing accessList is a loud RpcProviderError (below). When set, the missing value is
202+
+ // allowed through and stored as an honest NULL. Read once per call — cheap, hoisted above the
203+
+ // loop — so ops can flip it via env without a rebuild, and tests can toggle it per call. (issue #27)
204+
+ const allowMissingAccessList = Boolean(
205+
+ process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST,
206+
+ );
207+
const transactionIds = new Set<Hex>();
208+
209+
for (const transaction of transactions) {
210+
@@ -893,6 +1025,38 @@ export const standardizeTransactions = (
196211
transaction.gas = "0x0";
197212
}
198213

@@ -203,9 +218,12 @@ index 64bbcbe..bb3d6ad 100644
203218
+ // defaulting to `[]`, which would fabricate an empty list over a real one. Legacy (0x0), OP
204219
+ // deposit (0x7e), and unknown/system envelopes (e.g. Arbitrum internals) carry no access list
205220
+ // and are left untouched. An explicit `accessList: null` is treated the same as an omitted key
206-
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud.
221+
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud —
222+
+ // UNLESS the operator opts in via PORTAL_ALLOW_MISSING_ACCESS_LIST (see above), in which case
223+
+ // the missing value flows through to that honest NULL by design.
207224
+ const txType: string | undefined = transaction.type;
208225
+ if (
226+
+ !allowMissingAccessList &&
209227
+ (txType === "0x1" ||
210228
+ txType === "0x2" ||
211229
+ txType === "0x3" ||
@@ -216,7 +234,9 @@ index 64bbcbe..bb3d6ad 100644
216234
+ `Invalid RPC response: 'transaction.accessList' is a required property on type ${txType} transactions`,
217235
+ );
218236
+ error.meta = [
219-
+ "Please report this error to the RPC operator.",
237+
+ "This RPC provider omits the spec-required 'accessList' on typed transactions. If that is " +
238+
+ "expected for this endpoint (e.g. SQD's zkSync proxy), set PORTAL_ALLOW_MISSING_ACCESS_LIST=1 " +
239+
+ "to store the missing access list as NULL and continue; otherwise report this to the RPC operator.",
220240
+ requestText(request),
221241
+ ];
222242
+ error.stack = undefined;

portal/wiring/0.16.7.patch

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ index acea95b..c502c66 100644
4141
pollingInterval: number;
4242
finalityBlockCount: number;
4343
diff --git a/packages/core/src/rpc/actions.ts b/packages/core/src/rpc/actions.ts
44-
index 64bbcbe..bb3d6ad 100644
44+
index 64bbcbe..25c301c 100644
4545
--- a/packages/core/src/rpc/actions.ts
4646
+++ b/packages/core/src/rpc/actions.ts
4747
@@ -1,6 +1,8 @@
@@ -192,7 +192,22 @@ index 64bbcbe..bb3d6ad 100644
192192
/**
193193
* Helper function for "eth_getTransactionReceipt" request.
194194
*/
195-
@@ -893,6 +1017,33 @@ export const standardizeTransactions = (
195+
@@ -787,6 +911,14 @@ export const standardizeTransactions = (
196+
{ method: "eth_getBlockByNumber" | "eth_getBlockByHash" }
197+
>,
198+
): SyncTransaction[] => {
199+
+ // PORTAL_ALLOW_MISSING_ACCESS_LIST — opt-in to consuming from a non-standard RPC provider (e.g.
200+
+ // SQD's zkSync proxy) that omits the spec-required `accessList` on typed-envelope txs. Default
201+
+ // OFF: a missing accessList is a loud RpcProviderError (below). When set, the missing value is
202+
+ // allowed through and stored as an honest NULL. Read once per call — cheap, hoisted above the
203+
+ // loop — so ops can flip it via env without a rebuild, and tests can toggle it per call. (issue #27)
204+
+ const allowMissingAccessList = Boolean(
205+
+ process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST,
206+
+ );
207+
const transactionIds = new Set<Hex>();
208+
209+
for (const transaction of transactions) {
210+
@@ -893,6 +1025,38 @@ export const standardizeTransactions = (
196211
transaction.gas = "0x0";
197212
}
198213

@@ -203,9 +218,12 @@ index 64bbcbe..bb3d6ad 100644
203218
+ // defaulting to `[]`, which would fabricate an empty list over a real one. Legacy (0x0), OP
204219
+ // deposit (0x7e), and unknown/system envelopes (e.g. Arbitrum internals) carry no access list
205220
+ // and are left untouched. An explicit `accessList: null` is treated the same as an omitted key
206-
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud.
221+
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud —
222+
+ // UNLESS the operator opts in via PORTAL_ALLOW_MISSING_ACCESS_LIST (see above), in which case
223+
+ // the missing value flows through to that honest NULL by design.
207224
+ const txType: string | undefined = transaction.type;
208225
+ if (
226+
+ !allowMissingAccessList &&
209227
+ (txType === "0x1" ||
210228
+ txType === "0x2" ||
211229
+ txType === "0x3" ||
@@ -216,7 +234,9 @@ index 64bbcbe..bb3d6ad 100644
216234
+ `Invalid RPC response: 'transaction.accessList' is a required property on type ${txType} transactions`,
217235
+ );
218236
+ error.meta = [
219-
+ "Please report this error to the RPC operator.",
237+
+ "This RPC provider omits the spec-required 'accessList' on typed transactions. If that is " +
238+
+ "expected for this endpoint (e.g. SQD's zkSync proxy), set PORTAL_ALLOW_MISSING_ACCESS_LIST=1 " +
239+
+ "to store the missing access list as NULL and continue; otherwise report this to the RPC operator.",
220240
+ requestText(request),
221241
+ ];
222242
+ error.stack = undefined;

portal/wiring/0.16.8.patch

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ index acea95b..c502c66 100644
4141
pollingInterval: number;
4242
finalityBlockCount: number;
4343
diff --git a/packages/core/src/rpc/actions.ts b/packages/core/src/rpc/actions.ts
44-
index 64bbcbe..bb3d6ad 100644
44+
index 64bbcbe..25c301c 100644
4545
--- a/packages/core/src/rpc/actions.ts
4646
+++ b/packages/core/src/rpc/actions.ts
4747
@@ -1,6 +1,8 @@
@@ -192,7 +192,22 @@ index 64bbcbe..bb3d6ad 100644
192192
/**
193193
* Helper function for "eth_getTransactionReceipt" request.
194194
*/
195-
@@ -893,6 +1017,33 @@ export const standardizeTransactions = (
195+
@@ -787,6 +911,14 @@ export const standardizeTransactions = (
196+
{ method: "eth_getBlockByNumber" | "eth_getBlockByHash" }
197+
>,
198+
): SyncTransaction[] => {
199+
+ // PORTAL_ALLOW_MISSING_ACCESS_LIST — opt-in to consuming from a non-standard RPC provider (e.g.
200+
+ // SQD's zkSync proxy) that omits the spec-required `accessList` on typed-envelope txs. Default
201+
+ // OFF: a missing accessList is a loud RpcProviderError (below). When set, the missing value is
202+
+ // allowed through and stored as an honest NULL. Read once per call — cheap, hoisted above the
203+
+ // loop — so ops can flip it via env without a rebuild, and tests can toggle it per call. (issue #27)
204+
+ const allowMissingAccessList = Boolean(
205+
+ process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST,
206+
+ );
207+
const transactionIds = new Set<Hex>();
208+
209+
for (const transaction of transactions) {
210+
@@ -893,6 +1025,38 @@ export const standardizeTransactions = (
196211
transaction.gas = "0x0";
197212
}
198213

@@ -203,9 +218,12 @@ index 64bbcbe..bb3d6ad 100644
203218
+ // defaulting to `[]`, which would fabricate an empty list over a real one. Legacy (0x0), OP
204219
+ // deposit (0x7e), and unknown/system envelopes (e.g. Arbitrum internals) carry no access list
205220
+ // and are left untouched. An explicit `accessList: null` is treated the same as an omitted key
206-
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud.
221+
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud —
222+
+ // UNLESS the operator opts in via PORTAL_ALLOW_MISSING_ACCESS_LIST (see above), in which case
223+
+ // the missing value flows through to that honest NULL by design.
207224
+ const txType: string | undefined = transaction.type;
208225
+ if (
226+
+ !allowMissingAccessList &&
209227
+ (txType === "0x1" ||
210228
+ txType === "0x2" ||
211229
+ txType === "0x3" ||
@@ -216,7 +234,9 @@ index 64bbcbe..bb3d6ad 100644
216234
+ `Invalid RPC response: 'transaction.accessList' is a required property on type ${txType} transactions`,
217235
+ );
218236
+ error.meta = [
219-
+ "Please report this error to the RPC operator.",
237+
+ "This RPC provider omits the spec-required 'accessList' on typed transactions. If that is " +
238+
+ "expected for this endpoint (e.g. SQD's zkSync proxy), set PORTAL_ALLOW_MISSING_ACCESS_LIST=1 " +
239+
+ "to store the missing access list as NULL and continue; otherwise report this to the RPC operator.",
220240
+ requestText(request),
221241
+ ];
222242
+ error.stack = undefined;

portal/wiring/0.16.9.patch

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ index acea95b..c502c66 100644
4141
pollingInterval: number;
4242
finalityBlockCount: number;
4343
diff --git a/packages/core/src/rpc/actions.ts b/packages/core/src/rpc/actions.ts
44-
index 64bbcbe..bb3d6ad 100644
44+
index 64bbcbe..25c301c 100644
4545
--- a/packages/core/src/rpc/actions.ts
4646
+++ b/packages/core/src/rpc/actions.ts
4747
@@ -1,6 +1,8 @@
@@ -192,7 +192,22 @@ index 64bbcbe..bb3d6ad 100644
192192
/**
193193
* Helper function for "eth_getTransactionReceipt" request.
194194
*/
195-
@@ -893,6 +1017,33 @@ export const standardizeTransactions = (
195+
@@ -787,6 +911,14 @@ export const standardizeTransactions = (
196+
{ method: "eth_getBlockByNumber" | "eth_getBlockByHash" }
197+
>,
198+
): SyncTransaction[] => {
199+
+ // PORTAL_ALLOW_MISSING_ACCESS_LIST — opt-in to consuming from a non-standard RPC provider (e.g.
200+
+ // SQD's zkSync proxy) that omits the spec-required `accessList` on typed-envelope txs. Default
201+
+ // OFF: a missing accessList is a loud RpcProviderError (below). When set, the missing value is
202+
+ // allowed through and stored as an honest NULL. Read once per call — cheap, hoisted above the
203+
+ // loop — so ops can flip it via env without a rebuild, and tests can toggle it per call. (issue #27)
204+
+ const allowMissingAccessList = Boolean(
205+
+ process.env.PORTAL_ALLOW_MISSING_ACCESS_LIST,
206+
+ );
207+
const transactionIds = new Set<Hex>();
208+
209+
for (const transaction of transactions) {
210+
@@ -893,6 +1025,38 @@ export const standardizeTransactions = (
196211
transaction.gas = "0x0";
197212
}
198213

@@ -203,9 +218,12 @@ index 64bbcbe..bb3d6ad 100644
203218
+ // defaulting to `[]`, which would fabricate an empty list over a real one. Legacy (0x0), OP
204219
+ // deposit (0x7e), and unknown/system envelopes (e.g. Arbitrum internals) carry no access list
205220
+ // and are left untouched. An explicit `accessList: null` is treated the same as an omitted key
206-
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud.
221+
+ // (`== null`): encode.ts maps both to the same permanent NULL, so both must fail loud —
222+
+ // UNLESS the operator opts in via PORTAL_ALLOW_MISSING_ACCESS_LIST (see above), in which case
223+
+ // the missing value flows through to that honest NULL by design.
207224
+ const txType: string | undefined = transaction.type;
208225
+ if (
226+
+ !allowMissingAccessList &&
209227
+ (txType === "0x1" ||
210228
+ txType === "0x2" ||
211229
+ txType === "0x3" ||
@@ -216,7 +234,9 @@ index 64bbcbe..bb3d6ad 100644
216234
+ `Invalid RPC response: 'transaction.accessList' is a required property on type ${txType} transactions`,
217235
+ );
218236
+ error.meta = [
219-
+ "Please report this error to the RPC operator.",
237+
+ "This RPC provider omits the spec-required 'accessList' on typed transactions. If that is " +
238+
+ "expected for this endpoint (e.g. SQD's zkSync proxy), set PORTAL_ALLOW_MISSING_ACCESS_LIST=1 " +
239+
+ "to store the missing access list as NULL and continue; otherwise report this to the RPC operator.",
220240
+ requestText(request),
221241
+ ];
222242
+ error.stack = undefined;

0 commit comments

Comments
 (0)