Skip to content

Commit ac621ab

Browse files
committed
Security: close a critical injection and three broken invariants
An adversarial review found that three of eleven invariants did not hold as written, and that in the generated workflow none of them were load-bearing at all. Every case below was a working exploit before it was a test. CRITICAL -- sync.mjs spliced the webhook's safeAddress raw into a JS source string. A caller could close the string literal and append their own assemble() declaration, which hoists over the real one and bypasses all eleven guards at once, fully authoring the node's output. Verified against the real generated workflow. Fixed twice over: the template is now substituted as a bare JSON value rather than inside quotes, and a new gate-addr Condition rejects anything that is not a 20-byte address before it reaches the code node at all. I1 was broken. Nothing required the threshold to be >= 1, so threshold 0 returned executable:true with an empty signature blob -- and a NEGATIVE threshold was worse, because slice(0, -5) counts from the end, so two real signatures also became an empty blob. toBig accepted "-5" because BigInt does. Counts now parse through toCount, which rejects negatives. I7 was broken. It refused CONTRACT_SIGNATURE and APPROVED_HASH by reading signatureType -- an optional field a hostile payload omits. Both schemes are exactly 65 bytes, so they passed the shape check. v=0 is the dangerous one: checkSignatures reads s as an offset and CALLS the address in r, an attacker-controlled external call inside execTransaction. The guard now checks the v byte, which cannot be omitted. The assertion already existed in live-fixture.test.mjs; it just was not in the code. I3 failed open. lower() maps undefined, null and "" to the same empty string, so one stray "" in roster.json matched a missing safeAddress and opened the gate for every run. Falsy entries are filtered and the address shape is checked first. drain.mjs had two gates that did not gate. Gate 2 printed "inner reverts" and broadcast anyway -- real gas, nonce permanently consumed, nothing moved. Gate 3 asserted Response.ok, so a 2xx body carrying {success:false} passed what the header calls a preflight. Both now stop. A refusal exits 3 so a wrapper can tell it from an execution. Also: the queue reads had no ordering, and the service defaults to newest-first while the only executable entry is the LOWEST pending nonce -- so past 20 pending proposals gavel would have reported not-next-nonce forever, fail-closed but silently wrong. The workflow's hydrate node was unauthenticated, which would have turned a 401 into a plausible-looking empty queue. Malformed fields now produce named refusals instead of throwing an undeclared tenth outcome. Two tests were vacuous and are fixed. The case-insensitivity test compared an all-1s address to itself and would have passed if matching were case-sensitive. The test asserting the headline claim -- that the executor owns nothing -- compared the executor address against role handles like "O1", so it could never fail; it now resolves roles to real addresses. 64 tests. The live blob still simulates SUCCESS against the real Safe.
1 parent e6240cf commit ac621ab

7 files changed

Lines changed: 438 additions & 48 deletions

File tree

‎scripts/drain.mjs‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ console.log(`\n ${chain.name} (${chainId}) — draining ${safeAddress}`);
8484
if (!chain.receiptsEligible) console.log(` NOTE receiptsEligible=false — nothing here may enter EVIDENCE.md.`);
8585

8686
// ---- read: off-chain queue + on-chain state -------------------------------
87-
const res = await fetch(`${chain.txService}/safes/${safeAddress}/multisig-transactions/?executed=false&limit=20`,
87+
const res = await fetch(`${chain.txService}/safes/${safeAddress}/multisig-transactions/?executed=false&ordering=nonce&limit=20`,
8888
{ headers: { Authorization: `Bearer ${safeKey}` } });
8989
if (!res.ok) { console.error(` tx-service HTTP ${res.status} (addresses must be EIP-55 checksummed)`); process.exit(1); }
9090
const queue = await res.json();
@@ -99,7 +99,9 @@ const r = assemble({
9999
console.log(` queue ${queue.count} · nonce ${nonce} · threshold ${threshold}`);
100100
console.log(`\n [1/3] assemble ${r.executable ? 'EXECUTABLE' : `REFUSED — ${r.reason}`}`);
101101
console.log(` ${r.detail}`);
102-
if (!r.executable) process.exit(0); // a named refusal is a success, not an error
102+
// A named refusal is correct behaviour, not an error -- but a wrapper must be
103+
// able to tell "refused" from "executed". Exit 3 is the refusal channel.
104+
if (!r.executable) process.exit(3);
103105

104106
// ---- gate 2: local simulation ---------------------------------------------
105107
const executor = flags.executor && flags.executor !== true
@@ -113,7 +115,18 @@ try {
113115
const sim = await client.simulateContract({
114116
address: safeAddress, abi: EXEC_ABI, functionName: 'execTransaction', account: executor, args,
115117
});
116-
console.log(` [2/3] eth_call ${sim.result === true ? 'SUCCEEDS' : 'outer ok, inner reverts (inner-call-failed)'}`);
118+
if (sim.result !== true) {
119+
// execTransaction returns success=false when the INNER call reverts. The outer
120+
// call still succeeds whenever safeTxGas != 0 (Safe: require(success ||
121+
// safeTxGas != 0 || gasPrice != 0)), so this does not throw -- it just moves
122+
// nothing while permanently consuming the Safe nonce and burning real gas.
123+
// Printing it and broadcasting anyway was the bug.
124+
console.log(` [2/3] eth_call INNER CALL REVERTS -- would consume the nonce and move nothing`);
125+
console.log(` refusing to broadcast. This is the inner-call-failed / GS013 shape;`);
126+
console.log(` to produce it deliberately, use BENCH_GS013 rather than a live payout.`);
127+
process.exit(1);
128+
}
129+
console.log(` [2/3] eth_call SUCCEEDS`);
117130
} catch (e) {
118131
const msg = String(e.shortMessage || e.message || e).split('\n')[0];
119132
console.log(` [2/3] eth_call REVERTS — ${msg.slice(0, 120)}`);
@@ -141,8 +154,16 @@ const call = (payload) => fetch('https://app.keeperhub.com/api/execute/contract-
141154
}).then(async (x) => ({ ok: x.ok, status: x.status, json: await x.json().catch(() => ({})) }));
142155

143156
const pre = await call({ ...body, simulate: true });
144-
console.log(` [3/3] KeeperHub simulate HTTP ${pre.status} ${pre.ok ? 'OK' : 'FAILED'}`);
145-
if (!pre.ok) { console.log(` ${JSON.stringify(pre.json).slice(0, 300)}`); process.exit(1); }
157+
// pre.ok is Response.ok -- transport success. A 2xx body carrying
158+
// {success:false, revertReason:"GS026"} would have sailed through the "third
159+
// gate", which made it a liveness check on the API rather than a preflight on
160+
// the transaction. Inspect the body.
161+
const preBad = pre.json?.success === false || pre.json?.error || pre.json?.revertReason;
162+
console.log(` [3/3] KeeperHub simulate HTTP ${pre.status} ${pre.ok && !preBad ? 'OK' : 'FAILED'}`);
163+
if (!pre.ok || preBad) {
164+
console.log(` ${JSON.stringify(pre.json).slice(0, 300)}`);
165+
process.exit(1);
166+
}
146167

147168
if (!flags.execute) {
148169
console.log(`\n All three gates passed. Dry run — re-run with --execute to broadcast.\n`);

‎scripts/sync.mjs‎

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,20 @@ const VIEW_ABI = (name, outType) => JSON.stringify([
7474
*/
7575
function buildCodeBody() {
7676
const src = readFileSync(join(ROOT, 'src', 'assemble.mjs'), 'utf8');
77-
if (/^\s*import\s/m.test(src)) {
78-
console.error('assemble.mjs contains an import — it must stay dependency-free (invariant I10).');
79-
process.exit(1);
77+
// I10 must fail LOUDLY here, never silently in the sandbox. Static imports,
78+
// dynamic import(), and the eval-family are all rejected.
79+
for (const [re, what] of [
80+
[/^\s*import\s/m, 'a static import'],
81+
[/\bimport\s*\(/, 'a dynamic import()'],
82+
[/\beval\s*\(/, 'eval()'],
83+
[/\bnew\s+Function\s*\(/, 'new Function()'],
84+
[/\bMath\.random\s*\(/, 'Math.random()'],
85+
[/\bDate\.now\s*\(/, 'Date.now()'],
86+
]) {
87+
if (re.test(src)) {
88+
console.error(`assemble.mjs contains ${what} — it must stay pure and dependency-free (I10).`);
89+
process.exit(1);
90+
}
8091
}
8192
const stripped = src
8293
.replace(/^export default assemble;\s*$/m, '')
@@ -94,11 +105,24 @@ function buildCodeBody() {
94105
// caller-open door and the first check is bypassable through it.
95106
const ROSTER = ${roster};
96107
108+
// The webhook value is substituted as a JSON VALUE, never inside a string
109+
// literal. Splicing it between quotes let a caller close the string and append
110+
// their own assemble() declaration, which hoists over the real one and
111+
// bypasses every guard at once. gate-addr below rejects non-addresses before
112+
// this node runs; this line makes the splice non-syntactic regardless.
113+
const __safeAddress = {{@trigger-1:Webhook.safeAddress}};
97114
const __hydrated = {{@hydrate-1:Hydrate Signatures.data}};
98115
const __transactions = (__hydrated && __hydrated.results) ? __hydrated.results : [];
99116
117+
// Belt and braces: assemble refuses a malformed address anyway, but a value that
118+
// is not a string should never reach it from here.
119+
if (typeof __safeAddress !== "string") {
120+
return { executable: false, reason: "malformed-payload",
121+
detail: "safeAddress was not a string", signatures: "" };
122+
}
123+
100124
return assemble({
101-
safeAddress: "{{@trigger-1:Webhook.safeAddress}}",
125+
safeAddress: __safeAddress,
102126
roster: ROSTER,
103127
queue: { transactions: __transactions, count: __transactions.length },
104128
onchainNonce: ${chainId === '8453'
@@ -160,17 +184,26 @@ if (!web3Integration || web3Integration === true) {
160184
const nodes = [
161185
node('trigger-1', 'Webhook', 'trigger', { triggerType: 'Webhook' }, 0, 0),
162186

187+
// The first thing that touches caller input. A webhook (or the public
188+
// Marketplace listing) can send anything; nothing downstream should have to
189+
// assume otherwise. Rejecting non-addresses here means the injection vector in
190+
// the Code node is closed at the boundary as well as at the splice.
191+
node('gate-addr', 'Valid Address', 'action', {
192+
actionType: 'Condition',
193+
condition: '{{@trigger-1:Webhook.safeAddress}} matchesRegex ^0x[0-9a-fA-F]{40}$',
194+
}, 250, 0),
195+
163196
node('queue-1', 'Safe Queue', 'action', {
164197
actionType: 'safe/get-pending-transactions', network: chainId,
165198
safeAddress: SAFE_ADDR, integrationId: safeIntegration,
166-
}, 250, 0),
199+
}, 500, 0),
167200

168201
// Δ1 (RATIFIED, complexity.md §4a). Without it every idle sweep runs three
169202
// on-chain reads and the Code node, then posts "skipped: no work" — ~720 idle
170203
// messages a day across a 5-Safe roster, and the live feed stops being readable.
171204
node('gate-0', 'Has Work', 'action', {
172205
actionType: 'Condition', condition: '{{@queue-1:Safe Queue.count}} > 0',
173-
}, 500, 0),
206+
}, 750, 0),
174207

175208
...readNodes(),
176209

@@ -179,8 +212,17 @@ const nodes = [
179212
// arguments, two of which are the hostile-refund vector (DX-1). Verified live.
180213
node('hydrate-1', 'Hydrate Signatures', 'action', {
181214
actionType: 'HTTP Request', httpMethod: 'GET',
182-
endpoint: `${chain.txService}/safes/${SAFE_ADDR}/multisig-transactions/?executed=false&limit=20`,
183-
timeout: 10, failOnError: false,
215+
// ordering=nonce is load-bearing: the service defaults to -nonce (newest
216+
// first), and the only executable entry is the LOWEST pending nonce. On a
217+
// Safe with >20 pending proposals it would fall off page 1 and gavel would
218+
// report not-next-nonce forever — fail-closed, but silently and wrongly.
219+
endpoint: `${chain.txService}/safes/${SAFE_ADDR}/multisig-transactions/?executed=false&ordering=nonce&limit=20`,
220+
httpHeaders: JSON.stringify({ Authorization: 'Bearer ${SAFE_TX_SERVICE_JWT}' }),
221+
timeout: 10,
222+
// failOnError:false keeps a transient outage from killing the run. The cost is
223+
// that a 401 becomes an empty queue and a plausible-looking not-next-nonce, so
224+
// the header above is what stops that being a silent permanent stall.
225+
failOnError: false,
184226
}, 1500, 0),
185227

186228
node('assemble-1', 'Assemble', 'action', {
@@ -204,7 +246,10 @@ const nodes = [
204246
];
205247

206248
const edges = [
207-
{ id: 'e-trigger-queue', source: 'trigger-1', target: 'queue-1' },
249+
{ id: 'e-trigger-gateaddr', source: 'trigger-1', target: 'gate-addr' },
250+
// false branch is deliberately dangling: a malformed address ends the run
251+
// silently rather than posting noise to the live feed.
252+
{ id: 'e-gateaddr-queue', source: 'gate-addr', target: 'queue-1', sourceHandle: 'true' },
208253
{ id: 'e-queue-gate0', source: 'queue-1', target: 'gate-0' },
209254
{ id: 'e-gate0-threshold', source: 'gate-0', target: 'threshold-1', sourceHandle: 'true' },
210255
{ id: 'e-threshold-owners', source: 'threshold-1', target: 'owners-1' },

‎src/assemble.mjs‎

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,33 @@ export const PRE_BROADCAST_REFUSALS = Object.freeze(REFUSALS.slice(0, 7));
6161
* would silently disarm the single most dangerous guard in the
6262
* design. Refuse instead.
6363
*/
64-
export const SECURITY_REFUSALS = Object.freeze(['not-on-roster', 'incomplete-payload']);
64+
export const SECURITY_REFUSALS = Object.freeze([
65+
'not-on-roster', 'incomplete-payload', 'malformed-payload',
66+
]);
6567

6668
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
69+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
6770

6871
/** Signature types we refuse outright (invariant I7). */
6972
const REFUSED_SIGNATURE_TYPES = Object.freeze(['CONTRACT_SIGNATURE', 'APPROVED_HASH']);
7073

74+
/**
75+
* The ONLY `v` values checkSignatures treats as an ECDSA signature.
76+
* 27/28 — EIP-712 (eth_signTypedData)
77+
* 31/32 — eth_sign, i.e. 27/28 + 4
78+
*
79+
* Everything else is a different scheme wearing 65 bytes:
80+
* v = 0 CONTRACT_SIGNATURE. `s` is an offset into the blob and `r` is a
81+
* contract address that checkSignatures CALLS (EIP-1271). That is an
82+
* attacker-controlled external call from inside execTransaction.
83+
* v = 1 APPROVED_HASH. `r` is an owner address and no signature is verified.
84+
*
85+
* I7 previously refused these by reading `signatureType`, a field the service
86+
* supplies and an attacker-fed payload can simply omit. This set is checked on
87+
* the BYTES, which cannot be omitted.
88+
*/
89+
const PASSTHROUGH_V = Object.freeze([27, 28, 31, 32]);
90+
7191
/** The five fields the Safe plugin does not return; hydrate-1 must supply them. */
7292
const HYDRATED_FIELDS = Object.freeze([
7393
'safeTxGas', 'baseGas', 'gasPrice', 'gasToken', 'refundReceiver',
@@ -104,6 +124,29 @@ function isWellFormedSignature(sig) {
104124
return typeof sig === 'string' && /^0x[0-9a-fA-F]{130}$/.test(sig.trim());
105125
}
106126

127+
/** The trailing byte of a 65-byte signature, as a number. */
128+
function signatureV(sig) {
129+
return parseInt(String(sig).trim().slice(-2), 16);
130+
}
131+
132+
/**
133+
* A non-negative count, or null when the value is not one.
134+
*
135+
* `toBig` accepts "-5" happily, because BigInt does. A negative threshold made
136+
* `signerCount < required` false and then `slice(0, -5)` counted from the END and
137+
* returned an empty array — producing `executable: true` with an EMPTY signature
138+
* blob. Counts get their own parser so that cannot recur.
139+
*/
140+
function toCount(value) {
141+
try {
142+
const n = toBig(value, 'count');
143+
if (n < 0n || n > 1000n) return null;
144+
return Number(n);
145+
} catch {
146+
return null;
147+
}
148+
}
149+
107150
function refuse(reason, detail, provenance = {}) {
108151
return {
109152
executable: false,
@@ -140,23 +183,49 @@ export function assemble(input) {
140183
onchainOwners = [],
141184
} = input ?? {};
142185

143-
const transactions = Array.isArray(queue.transactions) ? queue.transactions : [];
186+
// ---- input validation ----------------------------------------------------
187+
// Every guard below assumes well-formed inputs. Rather than trusting that, the
188+
// malformed cases become a NAMED refusal here. Previously several of them threw
189+
// out of the function instead, which in the workflow is an errored Code node —
190+
// an undeclared tenth outcome whose effect on the downstream gate is untested.
191+
const transactions = Array.isArray(queue?.transactions) ? queue.transactions : [];
144192
const queueDepth = transactions.length;
145193

146-
const nonceOnchain = toBig(onchainNonce, 'onchainNonce');
147-
const thresholdOnchain = Number(toBig(onchainThreshold, 'onchainThreshold'));
148-
const owners = onchainOwners.map(lower);
149-
194+
const nonceOnchain = toCount(onchainNonce);
195+
const thresholdOnchain = toCount(onchainThreshold);
150196
const base = {
151-
onchainNonce: nonceOnchain.toString(),
152-
threshold: thresholdOnchain,
197+
onchainNonce: nonceOnchain === null ? '' : String(nonceOnchain),
198+
threshold: thresholdOnchain ?? 0,
153199
queueDepth,
154200
};
155201

202+
if (!ADDRESS_RE.test(String(safeAddress ?? ''))) {
203+
return refuse('malformed-payload', `safeAddress ${safeAddress} is not a 20-byte address.`, base);
204+
}
205+
if (nonceOnchain === null) {
206+
return refuse('malformed-payload', `onchainNonce ${onchainNonce} is not a valid count.`, base);
207+
}
208+
// A threshold of 0 is not a Safe. Treating it as one made an EMPTY signature
209+
// blob executable, which is the failure this check exists for.
210+
if (thresholdOnchain === null || thresholdOnchain < 1) {
211+
return refuse('malformed-payload', `onchainThreshold ${onchainThreshold} is not >= 1.`, base);
212+
}
213+
if (!Array.isArray(onchainOwners) || onchainOwners.length === 0) {
214+
return refuse('malformed-payload', 'onchainOwners is empty or not an array.', base);
215+
}
216+
const owners = onchainOwners.map(lower);
217+
156218
// ---- I3 · roster ---------------------------------------------------------
157219
// Checked here and not only in roster-1, because the Marketplace listing is a
158220
// caller-open door and roster-1 is bypassable through it.
159-
if (!roster.map(lower).includes(lower(safeAddress))) {
221+
//
222+
// Falsy entries are filtered BEFORE the membership test. lower() maps undefined,
223+
// null and "" all to "", so a single stray "" in roster.json used to match a
224+
// missing safeAddress and open the gate for every run.
225+
const rosterSet = (Array.isArray(roster) ? roster : [])
226+
.filter((a) => ADDRESS_RE.test(String(a ?? '')))
227+
.map(lower);
228+
if (!rosterSet.includes(lower(safeAddress))) {
160229
return refuse(
161230
'not-on-roster',
162231
`Safe ${safeAddress} is not on the opt-in roster; refusing to execute against it.`,
@@ -168,13 +237,7 @@ export function assemble(input) {
168237
// The service permits two DIFFERENT proposals at one nonce — that is how
169238
// "replace transaction" works. Choosing between them is a policy call gavel is
170239
// not entitled to make, so two candidates refuses just as firmly as zero.
171-
const candidates = transactions.filter((tx) => {
172-
try {
173-
return toBig(tx?.nonce, 'tx.nonce') === nonceOnchain;
174-
} catch {
175-
return false;
176-
}
177-
});
240+
const candidates = transactions.filter((tx) => toCount(tx?.nonce) === nonceOnchain);
178241
const candidateCount = candidates.length;
179242
const prov = { ...base, candidateCount };
180243

@@ -196,7 +259,7 @@ export function assemble(input) {
196259
}
197260

198261
const tx = candidates[0];
199-
const withNonce = { ...prov, nonce: toBig(tx.nonce, 'tx.nonce').toString(), safeTxHash: String(tx.safeTxHash ?? '') };
262+
const withNonce = { ...prov, nonce: String(toCount(tx.nonce)), safeTxHash: String(tx.safeTxHash ?? '') };
200263

201264
// ---- payload completeness (precondition for I4) --------------------------
202265
const missing = HYDRATED_FIELDS.filter((f) => tx[f] === undefined || tx[f] === null || tx[f] === '');
@@ -224,7 +287,15 @@ export function assemble(input) {
224287
// The worst outcome in the design: execTransaction pays the refund from the
225288
// Safe to refundReceiver and CALLS gasToken, so a hostile token can re-enter.
226289
// The party this is aimed at is whoever executes — us.
227-
const gasPrice = toBig(tx.gasPrice, 'tx.gasPrice');
290+
// Parsed fail-closed: an unparseable gasPrice is refused rather than thrown.
291+
// This is the guard against the drain vector, so "I could not read it" and
292+
// "it was hostile" must reach the same outcome.
293+
let gasPrice = null;
294+
try { gasPrice = toBig(tx.gasPrice, 'tx.gasPrice'); } catch { gasPrice = null; }
295+
if (gasPrice === null) {
296+
return refuse('malformed-payload',
297+
`gasPrice="${tx.gasPrice}" is not integer-like; refusing rather than assuming zero.`, withNonce);
298+
}
228299
if (gasPrice !== 0n || !isZeroAddress(tx.gasToken) || !isZeroAddress(tx.refundReceiver)) {
229300
return refuse(
230301
'refund-requested',
@@ -253,6 +324,21 @@ export function assemble(input) {
253324
);
254325
}
255326

327+
// The check that actually holds. signatureType is an OPTIONAL off-chain label and
328+
// a hostile payload simply omits it; v is in the bytes and cannot be. v=0 is an
329+
// EIP-1271 contract signature, where checkSignatures CALLS the address in `r` --
330+
// an attacker-controlled external call from inside execTransaction. v=1 is an
331+
// approved-hash, where nothing is verified at all. Both are exactly 65 bytes.
332+
const badV = confirmations.find((c) => !PASSTHROUGH_V.includes(signatureV(c.signature)));
333+
if (badV) {
334+
const v = signatureV(badV.signature);
335+
return refuse(
336+
'eip1271-unsupported',
337+
`Confirmation from ${badV.owner} has v=${v}; only EOA/eth_sign (27, 28, 31, 32) are spliced.`,
338+
withNonce,
339+
);
340+
}
341+
256342
// ---- I6 · every confirming owner is STILL an owner ----------------------
257343
// The silent GS026: a signature collected legitimately last week from an owner
258344
// removed yesterday. Naive tooling broadcasts it.
@@ -277,8 +363,13 @@ export function assemble(input) {
277363
// ---- I1 · never execute below threshold ---------------------------------
278364
// Required is the MAX of the queue's cached value and the live on-chain read;
279365
// trusting the cached one alone is the silent failure this guard exists for.
280-
const thresholdQueue = Number(toBig(tx.confirmationsRequired ?? thresholdOnchain, 'tx.confirmationsRequired'));
366+
const thresholdQueue = toCount(tx.confirmationsRequired) ?? thresholdOnchain;
281367
const required = Math.max(thresholdQueue, thresholdOnchain);
368+
// thresholdOnchain is already >= 1, so required is too. Asserted anyway: this is
369+
// the line whose absence made an empty blob executable, and it costs nothing.
370+
if (!Number.isInteger(required) || required < 1) {
371+
return refuse('malformed-payload', `computed threshold ${required} is not >= 1.`, withSigners);
372+
}
282373

283374
if (signerCount < required) {
284375
// Distinguish a plain shortfall from the drift case, where the queue believed
@@ -293,6 +384,18 @@ export function assemble(input) {
293384
}
294385

295386
// Exactly `required` signatures, ascending — checkSignatures needs no more.
387+
if (!ADDRESS_RE.test(String(tx.to ?? ''))) {
388+
return refuse('malformed-payload', `to ${tx.to} is not a 20-byte address.`, withSigners);
389+
}
390+
// Every remaining field is coerced below. Any that is not integer-like would
391+
// throw out of the function and become an errored code node rather than a named
392+
// outcome, so they are checked here while a refusal is still possible.
393+
for (const f of ['value', 'safeTxGas', 'baseGas']) {
394+
try { toBig(tx[f] ?? 0, f); } catch {
395+
return refuse('malformed-payload', `${f}="${tx[f]}" is not integer-like.`, withSigners);
396+
}
397+
}
398+
296399
const chosen = distinct.slice(0, required);
297400
const signatures = '0x' + chosen.map((c) => c.signature.trim().replace(/^0x/, '')).join('');
298401

0 commit comments

Comments
 (0)