Skip to content

Typescript Library and Monorepo#11

Open
reednaa wants to merge 93 commits intomainfrom
ts-library
Open

Typescript Library and Monorepo#11
reednaa wants to merge 93 commits intomainfrom
ts-library

Conversation

@reednaa
Copy link
Copy Markdown
Member

@reednaa reednaa commented Dec 16, 2025

Fixes: https://linear.app/lifi-linear/issue/API-174/create-catapultar-library-with-webauth-and-viem-support

Implements:

New Features

Adds support for new key types:

  • P256
  • WebAuthn

Along with supporting an additionally SHA256 hash of the payload before signature validation.

Set Signatures & execution payloads as pre-validated. This allows the ability to schedule calls where one calls execution allows a follow up call to be executed along with other composability features.

Multichain actions without the chainId set in signed domain separators using: Solady's _hashTypedDataSansChainId.

Mono Repo

This repository now uses a mono-repo for both the smart account and a companion typescript library.

Summary by CodeRabbit

  • New Features

    • Multi-key ownership (ECDSA/P256/WebAuthn), per-digest approvals, deterministic account factory, on-chain constrained-asset validator and call proxy; new TypeScript SDK for accounts, transactions, deploy/predict, and signature handling.
  • Tests

    • Large new Solidity and TypeScript test suites and refreshed snapshots covering account, factory, validator, proxy, executor, and transaction flows.
  • Documentation

    • New READMEs and developer guide for Solidity and TypeScript packages.
  • Chores

    • Monorepo reorganization, updated ignore/remapping patterns, build configs, and CI workflows.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@solidity/README.md`:
- Line 308: Fix the typo in the README sentence: change "valdiated" to
"validated" in the line that reads about signed `Outcome`s being validated by
recording balances before spending `Allowance` and comparing after the external
call (references: `Outcome`, `Allowance`).
- Line 302: The README contains a typo in the sentence describing CATValidator:
change the misspelled word "execue" to "execute" in the line referencing
CATValidator, CallProxy and CATValidator::CALL_PROXY so the sentence reads that
CallProxy "safely execute arbitrary calldata" (retain references to
CATValidator, CallProxy, and CALL_PROXY exactly as-is).

In `@solidity/src/CallProxy.sol`:
- Around line 15-24: The fallback() assembly uses calldatacopy with
sub(calldatasize(), 32) which underflows if calldata < 32 bytes; add a guard at
the start of the assembly block to check calldatasize() >= 32 (e.g., use
iszero(lt(calldatasize(), 32))) and revert or return early if the check fails,
so the subsequent calldatacopy and calldataload of the 32-byte target cannot
underflow; reference the fallback() function, the calldatasize(), calldatacopy,
and the sub(calldatasize(), 32) expression when applying this change.

In `@typescript/src/transaction/constrainedtransaction.spec.ts`:
- Around line 17-25: The test helper waitForTransaction currently sleeps 50ms
then calls createPublicClient(...).getTransactionReceipt({ hash }), which is
flaky; replace this with viem's polling helper by calling
waitForTransactionReceipt on the created publicClient (or import the standalone
waitForTransactionReceipt) with the transaction hash and desired
confirmations/timeout so it polls until mined; update the function
waitForTransaction to return await publicClient.waitForTransactionReceipt({
hash, confirmations?: number, timeout?: number }) (or equivalent options)
instead of the fixed sleep + single getTransactionReceipt call.
- Around line 88-94: The mint call via executor.writeContract is not awaited,
causing races before calling getTransactionCount and subsequent sendTransaction;
update the test to await the mint transaction (and wait for it to be mined) when
calling executor.writeContract for token1 (and any other mint calls), then only
call getTransactionCount() and perform sendTransaction after the awaited/minted
transaction completes to ensure balances and nonces are finalized.
🧹 Nitpick comments (13)
typescript/scripts/rewrite-dist-imports.mjs (1)

1-5: Resolve dist/ relative to the script to avoid CWD dependence.

In monorepos/workspaces, npm/pnpm/yarn may run scripts with the repo root as CWD. path.resolve("dist") would then point at the wrong directory or fail. Prefer resolving relative to this script.

Proposed change
 import fs from "node:fs/promises";
 import path from "node:path";
+import { fileURLToPath } from "node:url";

-const distDir = path.resolve("dist");
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const distDir = path.resolve(__dirname, "..", "dist");
solidity/README.md (2)

25-27: Clarify "Multiple Keys" table entry.

The value "1" in the "Multiple Keys" row is inconsistent with the Yes/No pattern used in other rows. Consider using "Single" or "No (single key)" for clarity.

📝 Suggested clarification
-| Multiple Keys                   |       1       |          Yes           | Yes (K1Validator) |  With Module   |
+| Multiple Keys                   |  Single Key   |          Yes           | Yes (K1Validator) |  With Module   |

108-108: Clarify ERC-7821 compatibility statement.

The statement "Catapultar is not ERC-7821 compatible but it follows ERC-7821 specification" appears contradictory. If it follows the specification, it should be compatible. Consider clarifying whether this means:

  • Partial implementation of the spec
  • Implementation without formal compatibility claim
  • A specific interpretation difference
solidity/src/CATValidator.sol (3)

228-233: Dead code in assembly error handling block.

The inner if iszero(success) check on line 230 is redundant since this code is already inside the if iszero(success) block from line 228. The revert will always execute.

🔧 Proposed fix to remove redundant check
             if iszero(success) {
                 returndatacopy(0x00, 0x00, returndatasize())
-                if iszero(success) {
-                    revert(0x00, returndatasize())
-                }
+                revert(0x00, returndatasize())
             }

99-101: Nonce setting is correct but could be clearer.

Setting spentNonces[account][nonce] = !spent works because spent is guaranteed to be false at this point (otherwise we'd have reverted). However, using = true would be more explicit and readable.

✨ Suggested improvement for clarity
     function _checkNonce(
         address account,
         uint256 nonce
     ) internal {
         bool spent = spentNonces[account][nonce];
         if (spent) revert NonceAlreadySpent();
-        spentNonces[account][nonce] = !spent;
+        spentNonces[account][nonce] = true;
     }

165-171: Underflow on balance decrease will revert correctly.

Line 168 performs newBalance - recordedBalances[i] which will revert on underflow (Solidity 0.8+) if the balance decreased. This is the intended behavior per the contract's purpose, but worth documenting that a balance decrease causes a revert with an arithmetic underflow error rather than InvalidTokenAmount.

Consider adding a comment or a custom error for balance decrease scenarios for clearer error messages:

     function _compareOutcomes(
         address account,
         Outcome[] calldata outcomes,
         uint256[] memory recordedBalances
     ) internal view {
         for (uint256 i; i < outcomes.length; ++i) {
             Outcome calldata outcome = outcomes[i];
             uint256 newBalance = _balanceOf(account, outcome);
+            // Reverts with arithmetic underflow if balance decreased
             uint256 diff = newBalance - recordedBalances[i];
             if (diff < outcome.amount) revert InvalidTokenAmount(outcome.amount, diff);
         }
     }
solidity/script/setupanvil.s.sol (1)

4-4: Remove unused console import.

The console import from forge-std/src/Script.sol is not used in this script.

🧹 Remove unused import
-import { Script, console } from "forge-std/src/Script.sol";
+import { Script } from "forge-std/src/Script.sol";
typescript/src/types/types.ts (1)

159-159: Consider using a more explicit type for MaybeFactory.

The {} | Factory union means any object matches {}. This is technically correct but could lead to subtle bugs where an object with unrelated properties passes the type check.

Consider a discriminated union or explicit partial:

export type MaybeFactory = { factory?: undefined; template?: undefined } | Factory;

This makes it explicit that when factory/template aren't provided, they should be undefined rather than any arbitrary object matching.

solidity/test/CATValidator.t.sol (1)

226-234: Consider using a named constant for the magic value.

The value 57896044618658097711785492504343953926634992332820282019728792003956564819968 is 1 << 255 (SPEND_BALANCE_OF_MAGIC from CATValidator). Using a reference to the constant would improve readability.

✨ Suggested improvement
+    uint256 constant SPEND_BALANCE_OF_MAGIC = 1 << 255;
+
     function test_handleAllowances_0_spend_balanceOf(
         uint248 amount
     ) external {
         // ...
         AllowanceSpend[] memory targets = new AllowanceSpend[](1);
         targets[0] = AllowanceSpend({
             token: token,
             allocated: 1 << 255,
-            spend: 57896044618658097711785492504343953926634992332820282019728792003956564819968
+            spend: SPEND_BALANCE_OF_MAGIC
         });
typescript/src/catapultar/account.spec.ts (1)

16-24: Consider making the polling delay configurable or using viem's built-in waitForTransactionReceipt.

The hardcoded 50ms delay before checking the receipt may be insufficient under load or on slower systems, potentially causing flaky tests. Consider using viem's waitForTransactionReceipt with appropriate timeout settings instead.

♻️ Suggested improvement
 async function waitForTransaction(hash: `0x${string}`) {
-  await new Promise((resolve) => setTimeout(resolve, 50));
-  // We need to wait for the transaction to be finalised.
   const publicClient = createPublicClient({
     chain: anvil,
     transport: http(rpcUrl()),
   });
-  await publicClient.getTransactionReceipt({ hash });
+  return await publicClient.waitForTransactionReceipt({ hash });
 }
typescript/src/transaction/transaction.ts (1)

124-141: Unexpected side effect: getOpData mutates this.signature.

The getOpData method has a side effect at lines 132-134 where it reassigns this.signature to its compact form. This is unexpected for a getter-style method and could cause issues if called multiple times or in different contexts.

Consider either:

  1. Renaming to indicate mutation (e.g., buildOpData)
  2. Using a local variable instead of mutating this.signature
♻️ Proposed fix to avoid mutation
   getOpData(options?: { compactSignature: boolean }): `0x${string}` {
     if (this.nonce === 0n)
       throw new Error(
         "Nonce 0 is not allowed. It cannot be differentiated from an invalid nonce.",
       );
     if (!this.nonce) throw new Error("No nonce has been set");
     const { compactSignature = true } = options ?? {};
+    let sig = this.signature;
-    if (this.signature) {
-      this.signature = compactSignature
-        ? this.asCompactSignature()
-        : this.signature;
-    }
-    if (this.signature) {
-      return `0x${asHex(this.nonce, 32)}${this.signature.replace("0x", "")}`;
+    if (sig) {
+      sig = compactSignature ? this.asCompactSignature(sig) : sig;
+      return `0x${asHex(this.nonce, 32)}${sig.replace("0x", "")}`;
     } else {
       return asHex(this.nonce, 32, "0x");
     }
   }
typescript/src/catapultar/catapultar.spec.ts (1)

364-365: Remove unused code.

These lines create an Account instance and access signTypedData but the result is never used. This appears to be leftover debugging code.

♻️ Proposed fix
-      const a = Account.fromP256(privateKey);
-      a.signTypedData;
-
       const wallet = privateKeyToAccount(PUBLIC_DEFAULT_ANVIL_ACCOUNT_0);
typescript/src/catapultar/account.ts (1)

322-345: Consider using bitwise OR instead of addition for mask accumulation.

Line 329 uses mask += 1n << bit which works correctly when bits are unique, but using mask |= 1n << bit is more semantically correct for bitmap operations and would be resilient against any potential duplicate bits.

♻️ Suggested improvement
       let mask = 0n;
       for (const bit of bits) {
-        mask += 1n << bit;
+        mask |= 1n << bit;
       }

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@solidity/script/setupanvil.s.sol`:
- Around line 36-40: The low-level call to
0x4e59b44847b379578588920cA78FbF26c0B4956C in the deployment block currently
ignores the (bool, bytes) return value, so failures go unnoticed; change the
invocation in the setup script (the call that uses .call{ gas: 1000000 }(...))
to capture the return values (e.g., bool success, bytes memory data) and add a
require(success) check (or revert with a clear message) after the call to ensure
deployment succeeded.

In `@solidity/src/CATValidator.sol`:
- Around line 228-233: The assembly block contains a redundant nested `if
iszero(success)`—remove the inner unreachable check and directly revert after
copying return data: when `iszero(success)` in the outer guard, call
`returndatacopy(0x00, 0x00, returndatasize())` followed by `revert(0x00,
returndatasize())`; update the block that uses the `success` flag (the assembly
section referencing `returndatacopy`, `returndatasize`, and `revert`) to
eliminate the inner `if` and perform the copy+revert unconditionally within the
outer failure branch.
🧹 Nitpick comments (17)
typescript/scripts/rewrite-dist-imports.mjs (1)

55-62: Avoid unbounded concurrency when rewriting files.

Promise.all over every file can hit EMFILE (too many open files) on large builds. Consider sequential processing or a small concurrency limit.

♻️ Safe sequential alternative
-const files = await walk(distDir);
-await Promise.all(
-  files.map(async (file) => {
-    const source = await fs.readFile(file, "utf8");
-    const updated = rewrite(source);
-    if (updated !== source) await fs.writeFile(file, updated);
-  }),
-);
+const files = await walk(distDir);
+for (const file of files) {
+  const source = await fs.readFile(file, "utf8");
+  const updated = rewrite(source);
+  if (updated !== source) await fs.writeFile(file, updated);
+}
typescript/src/catapultar/account.spec.ts (1)

16-24: Use waitForTransactionReceipt for robust, deterministic finality.
The current approach with a fixed 50ms sleep and single receipt fetch is unreliable. viem's waitForTransactionReceipt provides built-in polling with configurable timeout, retry logic with exponential backoff, and automatic replacement detection—all of which eliminate the need for arbitrary sleep durations.

🔁 Suggested change
 async function waitForTransaction(hash: `0x${string}`) {
-  await new Promise((resolve) => setTimeout(resolve, 50));
-  // We need to wait for the transaction to be finalised.
   const publicClient = createPublicClient({
     chain: anvil,
     transport: http(rpcUrl()),
   });
-  await publicClient.getTransactionReceipt({ hash });
+  await publicClient.waitForTransactionReceipt({ hash });
 }
typescript/src/transaction/transaction.ts (2)

131-135: Mutating this.signature in a getter-style method is a side effect.

getOpData mutates this.signature when compactSignature is true. This can cause unexpected behavior if the method is called multiple times or if the caller expects the original signature to remain unchanged.

Consider returning the compact signature without mutating the instance:

♻️ Proposed fix to avoid mutation
   getOpData(options?: { compactSignature: boolean }): `0x${string}` {
     if (this.nonce === 0n)
       throw new Error(
         "Nonce 0 is not allowed. It cannot be differentiated from an invalid nonce.",
       );
     if (!this.nonce) throw new Error("No nonce has been set");
     const { compactSignature = true } = options ?? {};
+    const sig = this.signature
+      ? compactSignature
+        ? this.asCompactSignature()
+        : this.signature
+      : undefined;
-    if (this.signature) {
-      this.signature = compactSignature
-        ? this.asCompactSignature()
-        : this.signature;
-    }
-    if (this.signature) {
-      return `0x${asHex(this.nonce, 32)}${this.signature.replace("0x", "")}`;
+    if (sig) {
+      return `0x${asHex(this.nonce, 32)}${sig.replace("0x", "")}`;
     } else {
       return asHex(this.nonce, 32, "0x");
     }
   }

167-180: Clarify why value is hardcoded to 0n in asCallData.

The method returns value: 0n while the transaction may contain calls with non-zero values (retrievable via getTotalValue()). If this is intentional because the value is encoded within executionData, consider adding a brief doc comment to clarify this design choice for future maintainers.

typescript/src/transaction/constrainedtransaction.ts (2)

132-142: Variable typehash shadows outer scope declaration.

The typehash variable declared on line 132 shadows the typehash from line 98. While this works correctly due to block scoping, using a distinct name (e.g., refundTypehash) would improve readability and prevent accidental misuse.

♻️ Proposed rename for clarity
-      const typehash = hashTypedData({
+      const refundTypehash = hashTypedData({
         types: ExecutionConstraintTyped,
         primaryType: "ExecutionConstraint",
         message: refundExecutionConstraint,
         domain: {
           chainId: this.chainId,
           name: "CAT Validator",
           version: "1",
           verifyingContract: validator,
         },
       });
       calls.push({
         to: zeroAddress,
         value: 0n,
         data: encodeFunctionData({
           abi: CATAPULTAR_V0_1_0_ABI,
           functionName: "setSignature",
-          args: [typehash, DigestApproval.Signature],
+          args: [refundTypehash, DigestApproval.Signature],
         }),
       });

154-158: Document why nonce is hardcoded to 1n.

The transaction is always created with setNonce(1n). If this is intentional (e.g., first transaction on a newly deployed account), add a comment explaining this constraint. Otherwise, consider making the nonce configurable via the opt parameter.

solidity/test/CallProxy.t.sol (1)

38-40: **Unchecked return value from low-level call.**The return value from the low-level c.call(payload) is not captured or asserted. "When using expectRevert with low-level calls, the status boolean indicates whether the expectation was met, not whether the call reverted." While vm.expectCall verifies the call happened, the success status should still be captured and asserted for consistency with the other test functions.

♻️ Proposed fix to capture and assert return value
     function test_forwardCall(
         address target,
         bytes calldata cdata
     ) external {
         vm.assume(target.code.length == 0);
         bytes memory payload = abi.encodePacked(bytes32(uint256(uint160(target))), cdata);

         vm.expectCall(target, cdata);
-        c.call(payload);
+        (bool success,) = c.call(payload);
+        assertEq(success, true);
     }
typescript/src/catapultar/catapultar.spec.ts (1)

364-365: Remove unused variable a.

The variable a created from Account.fromP256(privateKey) is never used. The reference a.signTypedData on line 365 appears to be a stray statement with no effect.

🧹 Proposed removal of unused code
-      const a = Account.fromP256(privateKey);
-      a.signTypedData;
-
       const wallet = privateKeyToAccount(PUBLIC_DEFAULT_ANVIL_ACCOUNT_0);
typescript/src/config.ts (1)

10-11: Document that this is a well-known Anvil test private key.

PUBLIC_DEFAULT_ANVIL_ACCOUNT_0 is the first default Anvil account private key. Consider adding a comment clarifying this is intentionally public test data to prevent future security concerns.

📝 Proposed documentation addition
+/** Well-known first Anvil account private key - intentionally public for local testing only */
 export const PUBLIC_DEFAULT_ANVIL_ACCOUNT_0 =
   "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
solidity/src/libs/LibExecutionConstraint.sol (1)

46-58: Clarify that allowancesHash uses allocated not spend.

The function hashes allowance.allocated (line 54) rather than allowance.spend. This appears intentional for constraint validation (the constraint is pre-signed with the allocated amount, while spend can vary at execution time). Consider adding a NatSpec comment to clarify this design decision.

typescript/src/types/types.ts (1)

47-52: Remove or document commented-out Signable type.

The commented-out Signable type has no explanation. If it's planned for future use, add a TODO comment; otherwise, remove it to keep the codebase clean.

solidity/test/CATValidator.t.sol (2)

304-329: Hardcoded index i == 2 may cause test to skip native token path.

When ad.length < 3, the native token (ETH) branch at if (i == 2) is never exercised. Consider using a modulo-based approach or ensuring minimum array length.

💡 Suggested approach
-            if (i == 2) {
+            if (i == ad.length / 2 || (ad.length > 0 && i == 0)) {

Or add vm.assume(ad.length >= 3) to ensure the native token path is always tested.

Also applies to: 331-376


398-399: Non-specific revert expectation.

Using vm.expectRevert() without a selector makes the test fragile - it will pass for any revert reason. Consider specifying the expected error selector for more precise testing.

💡 Suggested fix
-        vm.expectRevert();
+        vm.expectRevert(abi.encodeWithSelector(CATValidator.InvalidTokenAmount.selector, 1, 0));
solidity/test/helpers/CATValidator.t.sol (1)

314-339: Same hardcoded index issue as the other test file.

The if (i == 2) pattern for native token testing has the same fragility issue - skipped when ad.length < 3.

Also applies to: 341-386

solidity/src/CATValidator.sol (2)

99-102: Consider simplifying nonce spend logic.

The expression !spent when spent is guaranteed to be false (due to the revert on line 100) could be simplified to true for clarity.

💡 Suggested simplification
     function _checkNonce(
         address account,
         uint256 nonce
     ) internal {
         bool spent = spentNonces[account][nonce];
         if (spent) revert NonceAlreadySpent();
-        spentNonces[account][nonce] = !spent;
+        spentNonces[account][nonce] = true;
     }

165-170: Underflow possible if balance decreases unexpectedly.

Line 168 computes newBalance - recordedBalances[i]. If the balance decreased (e.g., due to fees, reentrancy, or edge cases), this will revert with a panic rather than the descriptive InvalidTokenAmount error. Consider using checked subtraction with a clear error path.

💡 Suggested approach
-            uint256 diff = newBalance - recordedBalances[i];
-            if (diff < outcome.amount) revert InvalidTokenAmount(outcome.amount, diff);
+            if (newBalance < recordedBalances[i]) revert InvalidTokenAmount(outcome.amount, 0);
+            uint256 diff = newBalance - recordedBalances[i];
+            if (diff < outcome.amount) revert InvalidTokenAmount(outcome.amount, diff);
typescript/src/catapultar/account.ts (1)

327-330: Consider using bitwise OR for mask accumulation.

Using += works for non-overlapping bits but |= is the idiomatic choice for bitmask operations and is safer if bits could overlap (e.g., duplicate nonces in input).

💡 Suggested fix
       let mask = 0n;
       for (const bit of bits) {
-        mask += 1n << bit;
+        mask |= 1n << bit;
       }

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@solidity/src/CATValidator.sol`:
- Around line 179-194: _handleAllowances currently calls
SafeTransferLib.safeTransferFrom for every AllowanceSpend, which will revert for
native ETH (token == address(0)); update _handleAllowances to explicitly detect
allowance.token == address(0) and revert with a clear descriptive error (e.g.,
NativeTokenNotSupported or revert with a message) before computing spend or
calling SafeTransferLib.safeTransferFrom, referencing AllowanceSpend,
SPEND_BALANCE_OF_MAGIC and SafeTransferLib.safeTransferFrom so reviewers can
locate the change.
- Around line 67-87: The entry function currently treats nonce==0 as "never
checked" allowing unlimited replay; add an on-chain cancellation mechanism by
(a) implementing a public revokeApproval or cancelNonce function (e.g.,
revokeApproval(uint256 nonce) / cancelNonceFor(address signer, uint256 nonce) or
a dedicated cancelNonce0(address signer)) that marks a nonce as used/invalid in
the same nonce tracking storage used by _checkNonce, and (b) update _checkNonce
and _validateApproval (or add a new internal path) so that when a signer has
revoked nonce 0 the entry call will revert; reference the entry, _checkNonce,
_validateApproval symbols when modifying logic and the nonce-tracking storage so
revocations for nonce 0 are honored by entry.
- Around line 160-171: _guard the subtraction in _compareOutcomes to avoid
underflow: inside _compareOutcomes (which calls _balanceOf) first check that
newBalance >= recordedBalances[i] and revert with a clear error if not, then
compute diff = newBalance - recordedBalances[i] and keep the existing check that
reverts InvalidTokenAmount(outcome.amount, diff) when diff < outcome.amount;
this prevents the Solidity underflow panic and preserves your custom
InvalidTokenAmount semantics.
- Around line 206-232: The assembly call currently forwards the contract's
entire balance via selfbalance(), which can unintentionally drain ETH; update
the call in the assembly block that constructs calldata for callProxy (the block
using execTarget, execPayload, callProxy) to pass 0 as the value parameter
instead of selfbalance() (or alternatively refactor the surrounding function to
accept an explicit uint256 value parameter and use that value in place of
selfbalance()); ensure the new value is used in the call(...) invocation and
adjust any related NatSpec/comments to reflect the change.

In `@typescript/README.md`:
- Line 107: Fix the grammar in the README sentence that currently reads
"Catapultar can be configured for this use case by embedded a call (or
signature) digest" by changing "by embedded a call" to "by embedding a call" so
the line becomes "Catapultar can be configured for this use case by embedding a
call (or signature) digest"; locate the sentence in the README.md (the line
containing that exact phrase) and update it accordingly.
- Around line 141-148: The example mistakenly declares `const tx =
tx.asCatapultarAllowanceTransaction()` which self-references an undefined `tx`;
change the call to use the previously created `cat` instance (i.e. call
`cat.asCatapultarAllowanceTransaction()`), then use that resulting `tx` for the
subsequent `tx.asAccount(...)` and `tx.asExecuteCall(...)` calls so
`ConstrainedAssetTransaction`, `cat`, and the methods
`asCatapultarAllowanceTransaction`, `asAccount`, and `asExecuteCall` are
referenced correctly.

In `@typescript/src/catapultar/account.ts`:
- Around line 322-345: In getSpendNoncesCalls, the bitmap construction uses mask
+= 1n << bit which incorrectly double-counts when nonces repeat; change the
update to use bitwise OR (mask |= 1n << bit) so setting a bit is idempotent;
locate mask in getSpendNoncesCalls where bits are iterated, replace the += with
|= and leave the rest (CATAPULTAR_V0_1_0_ABI and invalidateUnorderedNonces
encoding) unchanged.

In `@typescript/src/catapultar/catapultar.spec.ts`:
- Around line 129-134: The two identical assertions calling
tx.hasValidSignature({ noSignatureIsValid: true }) are redundant; update the
second assertion to call tx.hasValidSignature({ noSignatureIsValid: false }) so
you test both flag cases (i.e., keep the first assertion as-is and change the
duplicate to expect(await tx.hasValidSignature({ noSignatureIsValid: false
})).toBe(true)) referencing the tx.hasValidSignature(...) call in the spec.
- Around line 20-21: Remove the dead/unused Account usage: delete the import of
Account from "viem/tempo" and remove the code that constructs a via
Account.fromP256(privateKey) and accesses a.signTypedData (the unused variable
`a` and its signTypedData access are no-ops). Keep the necessary imports Base64
and P256 intact and ensure no other references to Account remain in the file.

In `@typescript/src/transaction/transaction.ts`:
- Around line 124-141: getOpData currently mutates this.signature when
compactSignature is true; instead compute the compacted signature into a local
variable and leave this.signature unchanged. In getOpData, introduce a local sig
(or similar) that is set to this.signature ? (compactSignature ?
this.asCompactSignature() : this.signature) : undefined and use sig when
building the returned hex string; do not reassign this.signature. Ensure
asCompactSignature() is still used to produce the compact form and that
downstream callers (e.g., asParameters or direct .signature access) observe the
original instance field unchanged.
🧹 Nitpick comments (21)
typescript/src/catapultar/catapultar.spec.ts (1)

32-40: Use waitForTransactionReceipt instead of a fixed delay + single receipt fetch.

A 50ms sleep followed by getTransactionReceipt is brittle and can cause flaky tests if the transaction isn't mined within that window. Viem provides waitForTransactionReceipt which polls until mined, with configurable retries and timeout.

Proposed fix
 async function waitForTransaction(hash: `0x${string}`) {
-  await new Promise((resolve) => setTimeout(resolve, 50));
-  // We need to wait for the transaction to be finalised.
   const publicClient = createPublicClient({
     chain: anvil,
     transport: http(rpcUrl()),
   });
-  return await publicClient.getTransactionReceipt({ hash });
+  return await publicClient.waitForTransactionReceipt({ hash });
 }
solidity/src/libs/LibExecutionConstraint.sol (1)

44-44: Naming inconsistency: OUTPUT_TYPE_HASH vs Outcome struct.

The constant is named OUTPUT_TYPE_HASH but it hashes the string "Outcome(...)" and corresponds to the Outcome struct. Consider renaming to OUTCOME_TYPE_HASH for consistency with the other constants (ALLOWANCE_TYPE_HASH maps to Allowance, EXECUTION_CONSTRAINT_TYPE_HASH maps to ExecutionConstraint).

Proposed fix
-    bytes32 constant OUTPUT_TYPE_HASH = keccak256(bytes("Outcome(address token,uint256 amount,address destination)"));
+    bytes32 constant OUTCOME_TYPE_HASH = keccak256(bytes("Outcome(address token,uint256 amount,address destination)"));

Then update references in outcomesHash (line 67) and the test file accordingly.

solidity/src/libs/KeyedOwnable.sol (1)

187-199: Stale key slot not cleared when downgrading from a 2-slot key to a 1-slot key.

When _transferOwnership switches from P256/WebAuthn (2 slots) to ECDSA (1 slot), slot index 1 (_OWNER_SLOT - 1) retains the old Y-coordinate. While publicKeyType gates which slots are semantically read, this leaves residual data in storage. Consider zeroing extra slots of the previous key type if the new key is shorter, especially as a defense-in-depth measure given the type confusion issue in _validateSignature.

Suggested approach
 function _transferOwnership(
     PublicKeyType ktp,
     bytes32[] calldata nextKey
 ) internal {
     if (!_isValidKey(ktp, nextKey)) revert InvalidKey();

+    // Clear stale slots if downgrading key length
+    uint256 prevLength = _keyTypeLength(publicKeyType);
     uint256 nextKeyLength = nextKey.length;
     for (uint256 i; i < nextKeyLength; ++i) {
         _setPublicKeySlice(i, nextKey[i]);
     }
+    for (uint256 i = nextKeyLength; i < prevLength; ++i) {
+        _setPublicKeySlice(i, bytes32(0));
+    }
     publicKeyType = ktp;
     emit OwnershipTransferred(ktp, nextKey);
 }
solidity/test/Integration.t.sol (1)

348-358: swap mock function uses fragile cumulative balance tracking.

The balanceOfSwap state variable tracks cumulative balance diffs, but diff will be 0 on subsequent calls if no new tokens are transferred between calls. Additionally, the function mints amount of outcome tokens regardless of actual diff received — this could mask bugs where the allowance transfer didn't send the expected amount.

For test purposes this works for a single invocation, but consider asserting diff == amount to validate that the correct amount was forwarded.

typescript/src/types/types.ts (1)

47-52: Remove commented-out Signable type.

Dead code that adds noise. If it's planned for future use, consider tracking it in an issue instead.

typescript/src/transaction/transaction.ts (1)

167-180: asCallData always returns value: 0n, ignoring getTotalValue().

If any calls contain non-zero value, the outer transaction must forward ETH. Returning value: 0n here means the caller must separately account for ETH. This may be intentional (ETH is forwarded via msg.value at a higher level), but it could mislead callers who expect the returned Call to be self-contained.

Consider at minimum adding a doc comment noting this intentional behavior.

typescript/src/config.ts (1)

10-11: PUBLIC_DEFAULT_ANVIL_ACCOUNT_0 is the well-known Anvil/Hardhat test private key.

The naming makes it clear this is a public test key. Ensure this is never referenced in production deployment paths. If it's only used in test files, consider moving it to a test-only config module to reduce the risk surface.

typescript/src/catapultar/account.spec.ts (2)

12-14: Minor duplication: PUBLIC_DEFAULT_ANVIL_ACCOUNT_0 is redefined locally.

This constant is already exported from typescript/src/config.ts. Consider importing it instead.

-const PUBLIC_DEFAULT_ANVIL_ACCOUNT_0 =
-  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
+import { PUBLIC_DEFAULT_ANVIL_ACCOUNT_0 } from "../config";

16-24: waitForTransaction does not handle the case where the receipt is unavailable.

getTransactionReceipt can throw if the transaction hasn't been mined yet. The 50ms delay is short and may not always suffice. Consider using waitForTransactionReceipt from viem, which polls until the receipt is available.

Suggested improvement
 async function waitForTransaction(hash: `0x${string}`) {
-  await new Promise((resolve) => setTimeout(resolve, 50));
-  // We need to wait for the transaction to be finalised.
   const publicClient = createPublicClient({
     chain: anvil,
     transport: http(rpcUrl()),
   });
-  await publicClient.getTransactionReceipt({ hash });
+  await publicClient.waitForTransactionReceipt({ hash });
 }
typescript/src/transaction/constrainedtransaction.ts (3)

109-117: Shadowed typehash variable name in refund block.

The const typehash declared at line 98 and the one at line 132 share the same name. While JavaScript scoping rules make this work (the inner one is in the if block), it reduces readability.

Also applies to: 143-151


109-111: to: zeroAddress for self-calls is a non-obvious convention — consider a named constant or comment.

Using zeroAddress as the to field for setSignature calls relies on the Catapultar smart account interpreting address(0) as a self-call. A brief inline comment explaining this convention (or a named constant like SELF_CALL_TARGET) would improve discoverability.


20-33: Consider making addAllowances and addOutcomes chainable.

Both addAllowances and addOutcomes mutate state but return void. Returning this would enable fluent-style chaining, consistent with BaseTransaction.addCall(...).

solidity/test/CallProxy.t.sol (1)

31-40: Return value of c.call(payload) is discarded in the success path.

In test_forwardCall, the (bool success, bytes memory) return from the low-level call is not captured. While vm.expectCall verifies the outbound call, asserting success == true would strengthen the test.

solidity/test/helpers/CATValidator.t.sol (2)

155-191: vm.assume inside a loop over fuzz array may cause excessive test rejections.

Line 163 uses vm.assume(amounts[i] >= 2) within a loop over the fuzzed amounts array. Each element must satisfy the constraint, and a single failure rejects the entire input. For large arrays, this significantly reduces the hit rate of valid fuzz inputs. Consider bounding the values using vm.bound(amounts[i], 2, type(uint256).max) instead if Foundry supports it, or moving to a bounded fuzz approach.


314-339: Hardcoded i == 2 for native ETH testing limits coverage.

The native ETH path (line 322: if (i == 2)) only runs when the fuzz array has at least 3 elements. Fuzz runs with ad.length <= 2 skip ETH handling entirely. Consider a separate dedicated test for ETH outputs rather than embedding it in a conditional on a fuzz index.

typescript/src/catapultar/account.ts (2)

220-226: publicClient() creates a new viem PublicClient on every invocation.

Methods like validateNonces, getNextValidNonce, and validateOwner call this.publicClient() which allocates a new transport and client each time. For methods that issue multiple RPC calls (e.g., getNextValidNonce loops up to attempts times), this is wasteful.

Consider caching the client instance (keyed on rpc + chainId), or at minimum reusing a single client within multi-call methods.


258-265: attachRpc mutates this while also returning a re-typed reference.

This is a surprise for callers who might expect immutability. The mutation of this.rpc and this.chainId means the original reference is also modified. Consider either documenting this clearly or returning a new instance.

solidity/src/CATValidator.sol (1)

95-102: Nit: !spent is always true here — prefer explicit = true.

Since line 100 reverts when spent is true, the value of spent on line 101 is guaranteed false, making !spent always true. Writing = true directly is clearer and avoids the cognitive indirection.

♻️ Suggested simplification
     function _checkNonce(
         address account,
         uint256 nonce
     ) internal {
-        bool spent = spentNonces[account][nonce];
-        if (spent) revert NonceAlreadySpent();
-        spentNonces[account][nonce] = !spent;
+        if (spentNonces[account][nonce]) revert NonceAlreadySpent();
+        spentNonces[account][nonce] = true;
     }
solidity/test/CATValidator.t.sol (3)

304-329: Hardcoded i == 2 for native-token branch may leave the path untested.

In test_fuzz_recordBalances (and identically in test_fuzz_compareOutcomes), the native-token code path is only exercised when ad.length >= 3. If the fuzzer generates arrays of length 0, 1, or 2, the address(0) token branch is never reached. Consider adding vm.assume(ad.length >= 3) or using a separate dedicated test for the native-token path.


225-230: Replace magic literal with a named constant for clarity.

The value 57896044618658097711785492504343953926634992332820282019728792003956564819968 is 1 << 255 (i.e., SPEND_BALANCE_OF_MAGIC). Since the contract's constant is internal, the test can't reference it directly, but defining a local constant in the test contract would improve readability.

♻️ Suggested approach
 contract CATValidatorTest is LibExecutionConstraintTest {
     CATValidatorMock validator;
+    uint256 constant SPEND_BALANCE_OF_MAGIC = 1 << 255;

Then replace the literals:

-        targets[0] = AllowanceSpend({
-            token: token,
-            allocated: 1 << 255,
-            spend: 57896044618658097711785492504343953926634992332820282019728792003956564819968
-        });
+        targets[0] = AllowanceSpend({
+            token: token,
+            allocated: SPEND_BALANCE_OF_MAGIC,
+            spend: SPEND_BALANCE_OF_MAGIC
+        });

Also applies to: 249-254


378-400: test_entry_decrease_balances: generic vm.expectRevert() weakens the assertion.

Line 398 uses vm.expectRevert() without specifying an expected error selector. This means the test passes on any revert, including unexpected ones (e.g., a signature validation failure, reentrancy guard, or an unrelated panic). Consider asserting the specific revert reason to ensure the test validates the intended outcome-checking logic.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@solidity/src/CATValidator.sol`:
- Around line 168-175: The unchecked block in _compareOutcomes allows
recordedBalances[i] + outcome.amount to wrap and silently bypass validation;
change this to a checked operation by removing the unchecked block (or
explicitly require outcome.amount <= type(uint256).max - recordedBalances[i]) so
the addition reverts on overflow, then compute the expected sum and use that to
validate newBalance and compute diff for InvalidTokenAmount; reference
recordedBalances, outcome.amount, newBalance, and the InvalidTokenAmount revert.

In `@typescript/README.md`:
- Around line 87-101: Remove the stray bare expression "CatapultarTx;" from the
README example; locate the example block that declares account and then creates
new CatapultarTx (symbols: CatapultarTx, tx, account) and delete that standalone
statement, and if the intent was to show an import, replace it with a proper
import line or a brief comment explaining where CatapultarTx comes from so the
example remains correct and non-confusing.

In `@typescript/src/catapultar/account.ts`:
- Around line 535-545: In validateOwner (method validateOwner on
CatapultarAccount), the ECDSA branch compares this.pubkey to the on-chain
actualAccountOwner using strict equality which is case-sensitive; normalize both
values (e.g., call .toLowerCase() on this.pubkey and actualAccountOwner) before
comparing so checksummed/mixed-case addresses match, keeping the same error
message and throwing behavior if they differ.
- Around line 180-193: ownerInSalt currently omits the keyType when computing
the owner hash and compares the full 32-byte keccak256 hex to a 20-byte salt
slice, so include the keyType in the keccak256 input and truncate the computed
hash to 20 bytes before comparing. Specifically, when computing saltPrefix in
ownerInSalt use keccak256 over an encodePacked that includes the key type
(AccountPublicKeyType value) and the keccak256 of the owner pubkey (use
pubkeyAsArray as before), then take the first 20 bytes (first 42 hex chars
including "0x") of that keccak result to produce saltPrefix; finally compare
that 20-byte hex string to the saltSlice (or convert both to BigInt
consistently) so the comparison between the computed saltPrefix and
opt.salt.slice(0, 42) is correct.

In `@typescript/src/transaction/transaction.ts`:
- Line 182: Fix the typo in the JSDoc comment that reads "Return the calls as an
approval diget. This can be used to "embed" the calls into an account" by
changing "diget" to "digest" so it reads "Return the calls as an approval
digest..." — update the comment near the relevant function in transaction.ts
where this exact sentence appears to correct the spelling.
🧹 Nitpick comments (2)
typescript/README.md (1)

81-83: TODO: Account deploy section is incomplete.

The "Account deploy" section only contains a TODO placeholder. If this is meant to be documented before release, consider filling it in.

Would you like me to draft the Account deploy documentation based on the CatapultarAccount.deploy static method?

typescript/src/catapultar/account.ts (1)

99-161: Generic V on deploy is unused at runtime — the constructed account always gets version "0.1.0".

The return type claims CatapultarAccount<V, undefined, AKT>, but the constructor call on line 155 doesn't pass a version, so it defaults to "0.1.0" regardless of what V resolves to. This makes the generic parameter misleading. Consider either removing V from deploy's signature or accepting a version in the options and forwarding it.

Comment on lines +180 to +193
private static ownerInSalt<AKT extends AccountPublicKeyType>(
opt: {
salt: `0x${string}`;
} & Pubkey<AKT>,
) {
const pubkeyArray = pubkeyAsArray(opt);
const saltPrefix =
opt.keyType === AccountPublicKeyType.ECDSAOrSmartContract
? opt.pubkey
: keccak256(encodePacked(["bytes32[]"], [pubkeyArray]));

const saltSlice = opt.salt.slice(0, 20 * 2 + 2);
return BigInt(saltSlice) === 0n || saltSlice === saltPrefix;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's check the actual implementation of ownerInSalt
cat -n typescript/src/catapultar/account.ts | sed -n '180,193p'

Repository: lifinance/catapultar

Length of output: 630


🏁 Script executed:

# Check the full context around this function
cat -n typescript/src/catapultar/account.ts | sed -n '175,200p'

Repository: lifinance/catapultar

Length of output: 921


🏁 Script executed:

# Look at the Solidity factory to understand how salt is handled
find . -name "CatapultarFactory.sol" -o -name "*Factory*.sol" | head -5

Repository: lifinance/catapultar

Length of output: 140


🏁 Script executed:

# Search for how salt is validated in Solidity
rg -n "salt" --type sol -A 5 -B 5 | grep -A 5 -B 5 -i "owner\|pubkey" | head -100

Repository: lifinance/catapultar

Length of output: 91


🏁 Script executed:

# Check if there are tests that show expected behavior for P256 salts
fd -e test.ts -e test.js | xargs grep -l "P256\|WebAuthn" | head -5

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Look for usage examples of ownerInSalt with P256 keys
rg -n "ownerInSalt" typescript/ -A 3 -B 3

Repository: lifinance/catapultar

Length of output: 1021


🏁 Script executed:

# Check the Solidity factory implementation
cat solidity/src/CatapultarFactory.sol | head -150

Repository: lifinance/catapultar

Length of output: 6682


🏁 Script executed:

# Look for salt-related validation in Solidity factory
rg -n "salt" solidity/src/CatapultarFactory.sol -B 3 -A 3

Repository: lifinance/catapultar

Length of output: 5919


🏁 Script executed:

# Check test files for P256 or WebAuthn salt examples
fd -e "*.ts" -e "*.js" typescript/test | head -10

Repository: lifinance/catapultar

Length of output: 429


🏁 Script executed:

# Search for P256 test cases
rg -n "P256\|WebAuthn" typescript/ -l

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Get the rest of the _saltPrefix function in Solidity
cat -n solidity/src/CatapultarFactory.sol | sed -n '147,170p'

Repository: lifinance/catapultar

Length of output: 856


🏁 Script executed:

# Look for the checkStartsWith function to understand what it does
rg -n "checkStartsWith" solidity/ -B 2 -A 5

Repository: lifinance/catapultar

Length of output: 565


🏁 Script executed:

# Check if there are any tests showing P256 salt encoding
fd . typescript/test --type f | head -5

Repository: lifinance/catapultar

Length of output: 88


🏁 Script executed:

# Look more carefully at the salt prefix computation in Solidity
rg -n "_saltPrefix\|checkStartsWith" solidity/src/CatapultarFactory.sol -A 10

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Let me check if pubkeyAsArray includes the keyType or just the pubkey data
cat -n typescript/src/catapultar/account.ts | sed -n '1,50p'

Repository: lifinance/catapultar

Length of output: 1929


🏁 Script executed:

# Find the pubkeyAsArray function definition
rg -n "pubkeyAsArray" typescript/src/catapultar/ -B 2 -A 8

Repository: lifinance/catapultar

Length of output: 5390


🏁 Script executed:

# Check how P256 pubkeys are encoded
rg -n "P256\|WebAuthn" typescript/src/catapultar/ -B 2 -A 2

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Find the pubkeyAsArray helper function
rg -n "export.*pubkeyAsArray\|function pubkeyAsArray" typescript/src/utils/ -A 15

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Look at the helpers file
cat typescript/src/utils/helpers.ts | head -100

Repository: lifinance/catapultar

Length of output: 3307


🏁 Script executed:

# Look at test cases to see if P256 salt validation is tested
cat -n typescript/src/catapultar/account.spec.ts | grep -A 30 "P256\|WebAuthn"

Repository: lifinance/catapultar

Length of output: 2800


🏁 Script executed:

# Check what the test parameters are
cat -n typescript/src/catapultar/account.spec.ts | sed -n '80,130p'

Repository: lifinance/catapultar

Length of output: 1958


🏁 Script executed:

# Look for how the Solidity hash is computed - search for EfficientHashLib
rg -n "EfficientHashLib" solidity/

Repository: lifinance/catapultar

Length of output: 2394


🏁 Script executed:

# Compare what the TypeScript encodes vs what Solidity encodes
# Look more carefully at the encodePacked vs EfficientHashLib.hash
cat -n typescript/src/catapultar/account.ts | sed -n '185,193p'

Repository: lifinance/catapultar

Length of output: 466


🏁 Script executed:

# Check if the keyType is supposed to be included in the hash
# Look for any encoding of keyType in the TypeScript
rg -n "keyType" typescript/src/catapultar/account.ts | head -20

Repository: lifinance/catapultar

Length of output: 381


🏁 Script executed:

# Let me verify what EfficientHashLib.hash does with the test to understand the expected behavior better
cat -n typescript/src/catapultar/account.spec.ts | sed -n '133,150p'

Repository: lifinance/catapultar

Length of output: 807


🏁 Script executed:

# Check the Solidity test to see how P256 salts are constructed there
cat solidity/test/CatapultarFactory.t.sol | head -200

Repository: lifinance/catapultar

Length of output: 7709


🏁 Script executed:

# Check if there are tests with non-zero P256 salt prefixes
rg -n "P256\|WebAuthn" solidity/test/ -A 20 -B 5

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Let me trace through what the actual hash should be by looking at the imports and understanding how EfficientHashLib works
python3 << 'EOF'
# Verify the mismatch between Solidity and TypeScript hashing
print("SOLIDITY SIDE:")
print("_saltPrefix for P256/WebAuthn:")
print("  EfficientHashLib.hash(bytes32(uint256(uint8(ktp))), EfficientHashLib.hash(owner))")
print("  - Includes keyType in the hash")
print("  - Result passed to address(bytes20(...))")
print("")
print("TYPESCRIPT SIDE:")
print("ownerInSalt for P256/WebAuthn:")
print("  keccak256(encodePacked(['bytes32[]'], [pubkeyArray]))")
print("  - Does NOT include keyType in the hash")
print("  - Result is full 32-byte hash (66 chars with 0x)")
print("  - saltSlice is first 20 bytes (42 chars with 0x)")
print("  - String comparison: 42 chars === 66 chars will always be false")
print("")
print("TEST BEHAVIOR:")
print("  P256 test uses salt: 0x${asHex(0n, 20)}${random(12)...}")
print("  This is: 0x + 40 zeros + random = zero-prefixed salt")
print("  ownerInSalt check: BigInt('0x' + 40 zeros) === 0n → TRUE")
print("  Test passes because of the 0n check, not the string comparison")
EOF

Repository: lifinance/catapultar

Length of output: 836


🏁 Script executed:

# Confirm that the keyType is missing from the TypeScript hash by looking at what EfficientHashLib does
# Search for how the keyType is used in Solidity
rg -n "uint8(ktp)" solidity/src/ -B 3 -A 3

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Let me trace through a concrete example to confirm the bug
python3 << 'EOF'
from hashlib import sha256, keccak

# Simulate what happens with P256 keys
# Example: P256 pubkey = [0x1234...abcd, 0x5678...ef01]

# TypeScript: keccak256(encodePacked(["bytes32[]"], [pubkeyArray]))
# This hashes the two 32-byte values
example_hash = "0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
print(f"TypeScript saltPrefix (P256): {example_hash}")
print(f"Length: {len(example_hash)} chars (32 bytes = 66 chars with 0x)")
print()

# TypeScript: saltSlice = opt.salt.slice(0, 42)
salt = "0x0000000000000000000000000000000000000000" + "123456abcdef"
saltSlice = salt[0:42]
print(f"Example salt: {salt}")
print(f"saltSlice: {saltSlice}")
print(f"Length: {len(saltSlice)} chars (20 bytes = 42 chars with 0x)")
print()

# The comparison
print(f"String comparison: saltSlice === saltPrefix")
print(f"  '{saltSlice}' === '{example_hash}'")
print(f"  Result: {saltSlice == example_hash}")
print()

# What the test passes with:
print("Current test behavior:")
print(f"  Test uses zero-prefixed salt: 0x{'0'*40}... (40 zeros)")
print(f"  BigInt('0x' + '0'*40) === 0n: True")
print(f"  So test passes via first condition (BigInt check), bypassing string comparison")
EOF

Repository: lifinance/catapultar

Length of output: 225


🏁 Script executed:

# Now verify that the Solidity side DOES include keyType by checking the actual hash computation
cat -n solidity/src/CatapultarFactory.sol | sed -n '156,165p'

Repository: lifinance/catapultar

Length of output: 572


🏁 Script executed:

# Search for any comments or documentation about why keyType might be excluded
rg -n "keyType\|ktp" typescript/src/catapultar/account.ts -B 2 -A 2 | grep -A 5 -B 5 "ownerInSalt"

Repository: lifinance/catapultar

Length of output: 46


🏁 Script executed:

# Check the git history or comments around ownerInSalt
rg -n "ownerInSalt" solidity/src/CatapultarFactory.sol -B 5 -A 15

Repository: lifinance/catapultar

Length of output: 5454


🏁 Script executed:

# Confirm the Solidity comment about the hash computation
cat -n solidity/src/CatapultarFactory.sol | sed -n '138,154p'

Repository: lifinance/catapultar

Length of output: 959


🏁 Script executed:

# Verify the Solidity specification one more time
cat -n solidity/src/CatapultarFactory.sol | sed -n '138,165p'

Repository: lifinance/catapultar

Length of output: 1476


ownerInSalt fails for P256/WebAuthn keys due to missing keyType in hash computation.

The TypeScript implementation omits keyType from the hash computation, deviating from the Solidity specification (line 142: "keccak256(ktp, keccak256(owner))") and implementation (line 163). Additionally, the resulting 32-byte hash (66 chars) is compared against a 20-byte salt slice (42 chars), causing the string comparison to always fail.

P256/WebAuthn tests pass only because they use zero-prefixed salts that satisfy BigInt(saltSlice) === 0n, bypassing the broken validation. Non-zero-prefixed salts will always fail validation regardless of correctness.

The fix requires including keyType in the hash and properly truncating to 20 bytes:

Proposed fix
     const pubkeyArray = pubkeyAsArray(opt);
     const saltPrefix =
       opt.keyType === AccountPublicKeyType.ECDSAOrSmartContract
         ? opt.pubkey
-        : keccak256(encodePacked(["bytes32[]"], [pubkeyArray]));
+        : ("0x" + keccak256(encodePacked(
+            ["uint8", "bytes32[]"],
+            [opt.keyType, pubkeyArray]
+          )).slice(2, 42)) as `0x${string}`;
🤖 Prompt for AI Agents
In `@typescript/src/catapultar/account.ts` around lines 180 - 193, ownerInSalt
currently omits the keyType when computing the owner hash and compares the full
32-byte keccak256 hex to a 20-byte salt slice, so include the keyType in the
keccak256 input and truncate the computed hash to 20 bytes before comparing.
Specifically, when computing saltPrefix in ownerInSalt use keccak256 over an
encodePacked that includes the key type (AccountPublicKeyType value) and the
keccak256 of the owner pubkey (use pubkeyAsArray as before), then take the first
20 bytes (first 42 hex chars including "0x") of that keccak result to produce
saltPrefix; finally compare that 20-byte hex string to the saltSlice (or convert
both to BigInt consistently) so the comparison between the computed saltPrefix
and opt.salt.slice(0, 42) is correct.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@solidity/src/CATValidator.sol`:
- Around line 169-174: The unchecked block formatting around the balance check
(the unchecked { ... } containing uint256 diff = newBalance >
recordedBalances[i] ? newBalance - recordedBalances[i] : 0; if (diff <
outcome.amount) { revert InvalidTokenAmount(outcome.amount, diff); }) is not
compliant with forge fmt; run `forge fmt` (or reformat that block) so
spacing/braces match project style and CI passes, keeping the logic and
identifiers newBalance, recordedBalances[i], diff, outcome.amount and the
InvalidTokenAmount revert unchanged.

In `@solidity/test/CATValidator.t.sol`:
- Around line 378-396: The failing CI check is due to formatting issues in the
test function test_compareOutcomes_revert_on_wrapped_required_amount; run `forge
fmt` (or apply the same formatter rules) to reformat
solidity/test/CATValidator.t.sol so the block containing
test_compareOutcomes_revert_on_wrapped_required_amount and its surrounding code
matches the project's solidity style; ensure the spacing and alignment around
the Outcome[] declaration, recordedBalances initialization, and vm.expectRevert
call are corrected so `forge fmt --check` passes.

In `@typescript/src/transaction/transaction.ts`:
- Around line 96-97: Fix the JSDoc typo in the comment block that currently
reads "@returns Whether the a multichain execution mode is set." — remove the
extra "the" so it reads "@returns Whether a multichain execution mode is set."
Update the JSDoc above the function (the JSDoc return line in the transaction.ts
comment block) to the corrected phrasing.
🧹 Nitpick comments (5)
typescript/src/transaction/transaction.ts (1)

199-234: asAccount hardcodes isSignature: false — document the intent.

Line 213 always passes isSignature: false when predicting the address. This means asAccount only supports embedding call digests, not signature digests. The distinction is important since it affects the derived address. Consider adding a brief JSDoc note clarifying this constraint.

solidity/src/CATValidator.sol (1)

95-102: Minor: !spent is always true here — consider using a literal for clarity.

Since line 100 reverts when spent == true, the assignment spentNonces[account][nonce] = !spent on line 101 is always equivalent to = true. Using !spent is a micro-gas trick (avoids a fresh PUSH1 1) but harms readability.

✏️ Optional clarity improvement
     function _checkNonce(
         address account,
         uint256 nonce
     ) internal {
         bool spent = spentNonces[account][nonce];
         if (spent) revert NonceAlreadySpent();
-        spentNonces[account][nonce] = !spent;
+        spentNonces[account][nonce] = true;
     }
solidity/test/CATValidator.t.sol (3)

214-234: Consider using a named constant for the magic value.

Lines 229 and 253 use the raw literal 57896044618658097711785492504343953926634992332820282019728792003956564819968 which is 1 << 255 (SPEND_BALANCE_OF_MAGIC). Referencing the constant directly would improve readability and reduce fragility.

✏️ Suggested improvement

You could expose SPEND_BALANCE_OF_MAGIC via the mock or reference it as a test constant:

-        targets[0] = AllowanceSpend({
-            token: token,
-            allocated: 1 << 255,
-            spend: 57896044618658097711785492504343953926634992332820282019728792003956564819968
-        });
+        uint256 SPEND_BALANCE_OF_MAGIC = 1 << 255;
+        targets[0] = AllowanceSpend({
+            token: token,
+            allocated: SPEND_BALANCE_OF_MAGIC,
+            spend: SPEND_BALANCE_OF_MAGIC
+        });

304-329: Hardcoded i == 2 for native ETH only tests that path when the fuzz array has 3+ elements.

If the fuzzer generates arrays with fewer than 3 entries, the native ETH balance path (token = address(0)) is never exercised. Consider adding a dedicated non-fuzz test for native ETH balance recording to guarantee coverage.


63-68: Consider adding an entry() happy-path integration test with non-zero nonce.

The current integration test (test_entry_decrease_balances) uses nonce 0 and an empty allowance array to test the revert path. A complementary test with a non-zero nonce, actual allowances, a real external call, and satisfied outcomes would exercise the full happy path and nonce-replay protection end-to-end.

reednaa and others added 3 commits February 16, 2026 15:42
* chore: address comments

Signed-off-by: georgi <georgi@li.finance>

* chore: address comments

Signed-off-by: georgi <georgi@li.finance>

* chore: address comments

Signed-off-by: georgi <georgi@li.finance>

* test: improve coverage

Signed-off-by: georgi <georgi@li.finance>

---------

Signed-off-by: georgi <georgi@li.finance>
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
Copy link
Copy Markdown

@aikido-pr-checks aikido-pr-checks bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3rd party Github Actions should be pinned - high severity
A third-party GitHub Action was imported, and is not pinned via a hash. This leaves your CI/CD at risk for potential supply chain attacks, if the affected GitHub Action is compromised.

Suggested change
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

working-directory: typescript

- name: Install Anvil
uses: foundry-rs/foundry-toolchain@v1
Copy link
Copy Markdown

@aikido-pr-checks aikido-pr-checks bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3rd party Github Actions should be pinned - high severity
A third-party GitHub Action was imported, and is not pinned via a hash. This leaves your CI/CD at risk for potential supply chain attacks, if the affected GitHub Action is compromised.

Suggested change
uses: foundry-rs/foundry-toolchain@v1
uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1.7.0

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

working-directory: typescript

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
Copy link
Copy Markdown

@aikido-pr-checks aikido-pr-checks bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3rd party Github Actions should be pinned - high severity
A third-party GitHub Action was imported, and is not pinned via a hash. This leaves your CI/CD at risk for potential supply chain attacks, if the affected GitHub Action is compromised.

Suggested change
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
Copy link
Copy Markdown

@aikido-pr-checks aikido-pr-checks bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3rd party Github Actions should be pinned - high severity
A third-party GitHub Action was imported, and is not pinned via a hash. This leaves your CI/CD at risk for potential supply chain attacks, if the affected GitHub Action is compromised.

Suggested change
uses: foundry-rs/foundry-toolchain@v1
uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1.7.0

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

working-directory: solidity

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
Copy link
Copy Markdown

@aikido-pr-checks aikido-pr-checks bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3rd party Github Actions should be pinned - high severity
A third-party GitHub Action was imported, and is not pinned via a hash. This leaves your CI/CD at risk for potential supply chain attacks, if the affected GitHub Action is compromised.

Suggested change
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

const files = [];

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
Copy link
Copy Markdown

@aikido-pr-checks aikido-pr-checks bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix
Suggested change
const fullPath = path.join(dir, entry.name);
const fullPath = path.resolve(dir, entry.name);
const relative = path.relative(distDir, fullPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
continue;
}

More info

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 Open source vulnerabilities detected - medium severity
Aikido detected 4 vulnerabilities across 2 packages, it includes 4 medium vulnerabilities.

Details

Remediation Aikido suggests bumping the vulnerable packages to a safe version.

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info


// forge-lint: disable-next-line(unchecked-call)
// wake-disable-next-line: unchecked-return-value
0x4e59b44847b379578588920cA78FbF26c0B4956C.call{ gas: 1000000 }(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large inline hex blob passed to low-level .call hides executable bytecode; replace with an imported/verifiable contract artifact or add provenance and explicit decoding to make intent auditable.

Details

✨ AI Reasoning
​A newly added script contains an inline, very large hex literal that is passed directly to a low-level .call on a hardcoded address. This embeds executable bytecode/data in a way that obscures intent and behavior. Such embedded hex blobs are opaque to reviewers and can hide arbitrary logic or backdoors. The pattern is not standard build artifact inclusion (it's a raw literal executed at runtime), which increases risk. Replacing with a referenced, named artifact or adding clear provenance would improve transparency.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

bytes calldata execPayload
) internal {
address callProxy = CALL_PROXY;
assembly ("memory-safe") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline assembly builds and forwards calldata via a proxy (calldatacopy, call, manual returndatacopy). This low-level construction obscures execution flow and can hide effects; avoid or add thorough justification and tests.

Details

✨ AI Reasoning
​The _call function constructs calldata manually and performs a low-level call through a proxy using assembly, copying calldata and using dynamic parameters (selfbalance, codesize()) and manual returndatacopy/revert handling. This manual, byte-level construction and handling obscures the exact external call flow and returned data handling, making it easy to hide or obfuscate side effects. Such patterns are legitimate in optimized low-level code, but they also enable concealed execution paths and should be minimized or clearly justified when introduced.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

/// The storage slot is intentionally chosen such that it overlaps with Solady's storage slot.
/// If an upgradeable contract by misfortune upgrades to an Ownable and not KeyedOwnable contract, then if the key
/// used is ECDSAOrSmartContract, there is a chance that the account is still in control of someone.
bytes32 internal constant _OWNER_SLOT = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff74873927;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nonstandard hard-coded owner storage slot (_OWNER_SLOT) is used to hide owner data; this reduces transparency and can conceal control. Use explicit state variables or document and rationalize thoroughly.

Details

✨ AI Reasoning
​The code introduces a hard-coded, non-obvious storage slot constant used as the base for owner data. Choosing an intentionally overlapping and obfuscated slot makes it difficult to reason about ownership and increases risk that ownership or key data is hidden from straightforward static analysis. Although comments reference the intent, the pattern still reduces transparency and can conceal backdoors or surprises in upgrades.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

) private {
assembly ("memory-safe") {
// Subtract the index so we get away from the "small" space.
sstore(sub(_OWNER_SLOT, index), val)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct sstore to a computed slot (sstore(sub(_OWNER_SLOT, index), val)) hides where ownership data is stored; this obscures control and makes audits harder. Avoid computed storage slots or clearly document and justify the layout.

Details

✨ AI Reasoning
​The code stores owner public key slices using direct sstore into a computed slot (subtracting an internal constant by index). This intentionally places ownership data in an unusual location and uses low-level storage arithmetic instead of explicit named state variables. While comments assert the choice is intentional, the pattern makes the storage layout non-obvious and can hide ownership control, complicating review and potentially masking malicious state changes. The change introduces direct, computed storage writes that obscure where owner data is kept and who controls the contract.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
typescript/src/transaction/transaction.ts (1)

33-37: Consider defensively cloning calls in the constructor.

this.calls = calls keeps external aliasing alive; mutating the original array later mutates transaction state implicitly.

Proposed fix
-    this.calls = calls;
+    this.calls = [...calls];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@typescript/src/transaction/transaction.ts` around lines 33 - 37, The
constructor currently assigns the incoming array directly (const { calls = [] }
= opt ?? {}; this.calls = calls) which preserves external aliasing; change the
assignment in the Transaction constructor to defensively clone the calls array
(e.g., use a shallow copy like Array.from(calls) or [...calls]) when setting
this.calls to prevent external mutations from affecting transaction state,
ensuring you update the assignment site that references calls in the
transaction.ts constructor.
solidity/src/CATValidator.sol (1)

95-102: Consider simplifying the nonce toggle.

The expression spentNonces[account][nonce] = !spent is equivalent to = true since spent is guaranteed false at this point (we just checked). While correct, the negation adds unnecessary cognitive overhead.

♻️ Suggested simplification
     function _checkNonce(
         address account,
         uint256 nonce
     ) internal {
         bool spent = spentNonces[account][nonce];
         if (spent) revert NonceAlreadySpent();
-        spentNonces[account][nonce] = !spent;
+        spentNonces[account][nonce] = true;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@solidity/src/CATValidator.sol` around lines 95 - 102, The _checkNonce
function currently reads spentNonces[account][nonce] into local bool spent,
checks and reverts if true, then sets spentNonces[account][nonce] = !spent;
simplify this by directly setting spentNonces[account][nonce] = true after the
revert check to remove the unnecessary negation—update the _checkNonce function
(and keep the NonceAlreadySpent() revert logic) so it explicitly writes true to
the spentNonces mapping.
solidity/test/CATValidator.t.sol (2)

304-329: Native ETH outcome testing is limited by index-based condition.

The test_fuzz_recordBalances and test_fuzz_compareOutcomes tests only assign native ETH when i == 2. With fuzzed array lengths, many runs will have fewer than 3 elements, meaning native ETH balance recording is rarely exercised.

Consider using a modulo check or separate dedicated test:

// Example: test native ETH for last element of any array
if (i == outcomes.length - 1 && outcomes.length > 0) {
    vm.deal(dest, ad[i].amount);
    outcomes[i] = Outcome({ amount: ad[i].amount, destination: ad[i].destination, token: address(0) });
    continue;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@solidity/test/CATValidator.t.sol` around lines 304 - 329, The tests
test_fuzz_recordBalances and test_fuzz_compareOutcomes currently only assign
native ETH when i == 2, which is skipped for fuzzed arrays shorter than 3;
update the native-ETH selection logic in both functions (the loop building
outcomes from AmountAndDestination[] ad and the outcomes array) to pick an
element that always exists for any length — for example, use the last element (i
== outcomes.length - 1 && outcomes.length > 0) or a modulo-based selection
(e.g., i % max(1, outcomes.length) == someIndex) instead of the hardcoded i == 2
so ETH balance recording is exercised for all fuzz sizes.

414-436: Good coverage of entry-level balance decrease revert.

The test correctly validates that the full entry flow reverts when the external call decreases the balance, exercising the outcome comparison guard. Consider specifying the expected revert selector for clearer failure diagnostics.

♻️ More specific revert expectation
-        vm.expectRevert();
+        vm.expectRevert(abi.encodeWithSelector(CATValidator.InvalidTokenAmount.selector, 1, 0));
         validator.entry(token, call, address(this), 0, allowances, outcomes, hex"");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@solidity/test/CATValidator.t.sol` around lines 414 - 436, Replace the generic
vm.expectRevert() in test_entry_decrease_balances with a specific revert
expectation that matches the validator.entry outcome-comparison guard's error
selector; locate the actual revert identifier emitted by validator.entry (the
outcome comparison error/exception in the Validator contract) and call
vm.expectRevert(abi.encodeWithSelector(<ValidatorError>.selector)) or
vm.expectRevert(<encoded error bytes>) so the test asserts the exact revert
reason instead of any revert.
solidity/test/libs/IntentExecutor.t.sol (1)

295-324: Consider adding a USDT-style token test for safeApproveWithRetry.

The test_repeatApprovalSavesGas validates gas savings for repeat approvals, but MockERC20 from Solady doesn't implement USDT-style "revert on non-zero-to-non-zero" approval behavior. The safeApproveWithRetry retry logic is never exercised.

Consider adding a MockUSDT that reverts on non-zero-to-non-zero approvals to validate that safeApproveWithRetry correctly handles this edge case:

contract MockUSDT is MockERC20 {
    function approve(address spender, uint256 amount) public override returns (bool) {
        if (amount != 0 && allowance(msg.sender, spender) != 0) {
            revert("USDT: approve from non-zero to non-zero");
        }
        return super.approve(spender, amount);
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@solidity/test/libs/IntentExecutor.t.sol` around lines 295 - 324, Add a
USDT-style mock and a test that exercises safeApproveWithRetry: create a
MockUSDT that overrides approve to revert on non-zero->non-zero (similar to the
provided snippet), deploy MockUSDT in the test (or setUp), then add a test
(e.g., test_repeatApprovalWithUSDTRetries) that first sets a non-zero allowance
for address(this) on the MockUSDT and then calls executor.executeAndSweep with
an IntentExecutor.Approval for MockUSDT to a non-zero target so the executor's
safeApproveWithRetry path runs; finally assert the executor's allowance is set
to type(uint256).max (or expected) and that no revert occurred. Reference
symbols: MockERC20, MockUSDT, IntentExecutor.Approval, executor.executeAndSweep,
safeApproveWithRetry, and the existing test_repeatApprovalSavesGas for
placement.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@typescript/src/transaction/transaction.ts`:
- Around line 27-37: The constructor currently assigns opt.nonce directly to
this.nonce allowing invalid values to bypass checks; update the constructor to
validate the nonce the same way setNonce does: require a bigint, enforce
non-negative and ensure it is within the allowed upper bound (e.g., < 2**64) and
throw a RangeError/TypeError for invalid values instead of assigning; likewise
tighten setNonce to explicitly reject negative or out-of-range values and
document the invariant on the nonce property to keep both constructor and
setNonce consistent (refer to constructor, setNonce, and this.nonce).

---

Nitpick comments:
In `@solidity/src/CATValidator.sol`:
- Around line 95-102: The _checkNonce function currently reads
spentNonces[account][nonce] into local bool spent, checks and reverts if true,
then sets spentNonces[account][nonce] = !spent; simplify this by directly
setting spentNonces[account][nonce] = true after the revert check to remove the
unnecessary negation—update the _checkNonce function (and keep the
NonceAlreadySpent() revert logic) so it explicitly writes true to the
spentNonces mapping.

In `@solidity/test/CATValidator.t.sol`:
- Around line 304-329: The tests test_fuzz_recordBalances and
test_fuzz_compareOutcomes currently only assign native ETH when i == 2, which is
skipped for fuzzed arrays shorter than 3; update the native-ETH selection logic
in both functions (the loop building outcomes from AmountAndDestination[] ad and
the outcomes array) to pick an element that always exists for any length — for
example, use the last element (i == outcomes.length - 1 && outcomes.length > 0)
or a modulo-based selection (e.g., i % max(1, outcomes.length) == someIndex)
instead of the hardcoded i == 2 so ETH balance recording is exercised for all
fuzz sizes.
- Around line 414-436: Replace the generic vm.expectRevert() in
test_entry_decrease_balances with a specific revert expectation that matches the
validator.entry outcome-comparison guard's error selector; locate the actual
revert identifier emitted by validator.entry (the outcome comparison
error/exception in the Validator contract) and call
vm.expectRevert(abi.encodeWithSelector(<ValidatorError>.selector)) or
vm.expectRevert(<encoded error bytes>) so the test asserts the exact revert
reason instead of any revert.

In `@solidity/test/libs/IntentExecutor.t.sol`:
- Around line 295-324: Add a USDT-style mock and a test that exercises
safeApproveWithRetry: create a MockUSDT that overrides approve to revert on
non-zero->non-zero (similar to the provided snippet), deploy MockUSDT in the
test (or setUp), then add a test (e.g., test_repeatApprovalWithUSDTRetries) that
first sets a non-zero allowance for address(this) on the MockUSDT and then calls
executor.executeAndSweep with an IntentExecutor.Approval for MockUSDT to a
non-zero target so the executor's safeApproveWithRetry path runs; finally assert
the executor's allowance is set to type(uint256).max (or expected) and that no
revert occurred. Reference symbols: MockERC20, MockUSDT,
IntentExecutor.Approval, executor.executeAndSweep, safeApproveWithRetry, and the
existing test_repeatApprovalSavesGas for placement.

In `@typescript/src/transaction/transaction.ts`:
- Around line 33-37: The constructor currently assigns the incoming array
directly (const { calls = [] } = opt ?? {}; this.calls = calls) which preserves
external aliasing; change the assignment in the Transaction constructor to
defensively clone the calls array (e.g., use a shallow copy like
Array.from(calls) or [...calls]) when setting this.calls to prevent external
mutations from affecting transaction state, ensuring you update the assignment
site that references calls in the transaction.ts constructor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e55c2ad-0036-48dc-9827-70358169b40a

📥 Commits

Reviewing files that changed from the base of the PR and between 59b3587 and 211d261.

📒 Files selected for processing (6)
  • solidity/script/deploy.s.sol
  • solidity/src/CATValidator.sol
  • solidity/src/libs/IntentExecutor.sol
  • solidity/test/CATValidator.t.sol
  • solidity/test/libs/IntentExecutor.t.sol
  • typescript/src/transaction/transaction.ts

Comment on lines +27 to +37
constructor(opt?: {
mode?: ExecutionMode;
nonce?: bigint;
calls?: Call[];
signature?: `0x${string}`;
}) {
const { mode, nonce, calls = [], signature } = opt ?? {};
this.mode = mode;
this.nonce = nonce;
this.calls = calls;
this.signature = signature;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce nonce invariants consistently (constructor + setter).

nonce can bypass validation in the constructor, and setNonce currently allows negative/out-of-range values. This can generate invalid opdata and fail downstream at execution time.

Proposed fix
 export class BaseTransaction {
+  private static readonly MAX_UINT256 = (1n << 256n) - 1n;
+
+  private assertValidNonce(nonce: bigint) {
+    if (nonce <= 0n)
+      throw new Error(
+        "Nonce must be greater than 0. 0 cannot be differentiated from an invalid nonce.",
+      );
+    if (nonce > BaseTransaction.MAX_UINT256)
+      throw new Error("Nonce exceeds uint256 range.");
+  }
+
   constructor(opt?: {
@@
   }) {
     const { mode, nonce, calls = [], signature } = opt ?? {};
     this.mode = mode;
-    this.nonce = nonce;
+    if (nonce !== undefined) this.assertValidNonce(nonce);
+    this.nonce = nonce;
     this.calls = calls;
     this.signature = signature;
   }
@@
   setNonce(nonce: bigint) {
-    if (nonce === 0n)
-      throw new Error(
-        `Nonce 0 is not allowed. It cannot be differentiated from an invalid nonce.`,
-      );
+    this.assertValidNonce(nonce);
     this.nonce = nonce;
     return this;
   }

Also applies to: 45-51

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@typescript/src/transaction/transaction.ts` around lines 27 - 37, The
constructor currently assigns opt.nonce directly to this.nonce allowing invalid
values to bypass checks; update the constructor to validate the nonce the same
way setNonce does: require a bigint, enforce non-negative and ensure it is
within the allowed upper bound (e.g., < 2**64) and throw a RangeError/TypeError
for invalid values instead of assigning; likewise tighten setNonce to explicitly
reject negative or out-of-range values and document the invariant on the nonce
property to keep both constructor and setNonce consistent (refer to constructor,
setNonce, and this.nonce).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants