Skip to content

feat: privy support#16

Merged
Will-Guan merged 9 commits intomainfrom
feat/privy-support
Apr 1, 2026
Merged

feat: privy support#16
Will-Guan merged 9 commits intomainfrom
feat/privy-support

Conversation

@gummy789j
Copy link
Copy Markdown
Collaborator

No description provided.

@gummy789j gummy789j force-pushed the feat/privy-support branch from d4ee2ae to f0851be Compare March 30, 2026 18:44
@gummy789j gummy789j force-pushed the feat/privy-support branch from f0851be to c4fba9a Compare March 31, 2026 03:04
@BofAI BofAI deleted a comment from github-actions Bot Mar 31, 2026
@github-actions
Copy link
Copy Markdown

Code Review Audit Report

PR Overview

Property Value
From Branch main
To Branch feat/privy-support
Commits 1 commit: c4fba9a feat: privy support
Files Changed 123 files
Insertions +9,370
Deletions -770

Change Summary

1. Core Privy Wallet Adapter (New Feature)

New PrivyAdapter class in both TypeScript (packages/typescript/src/core/adapters/privy.ts) and Python (packages/python/src/agent_wallet/core/adapters/privy.py). Implements the Wallet and Eip712Capable interfaces by delegating signing to the Privy API instead of a local private key. Supports EVM (via personal_sign, eth_signTransaction, eth_signTypedData_v4) and TRON (via raw_sign with local recovery-bit computation).

2. Privy HTTP Client (New Feature)

New PrivyClient class in TypeScript (packages/typescript/src/core/clients/privy.ts) and Python (packages/python/src/agent_wallet/core/clients/privy.py). Handles Basic Auth, rate-limit retry with exponential backoff, and error classification into PrivyAuthError, PrivyRateLimitError, and PrivyRequestError.

3. Privy Configuration (New Feature)

New PrivyConfig/PrivyConfigResolver in both languages under core/providers/privy-config.*. Validates app_id, app_secret, and wallet_id. The new PrivyWalletParams type extends WalletConfig in both config.ts and config.py.

4. Provider / Resolver Refactoring

  • EnvWalletProvider refactored to read from env vars lazily (via process.env / os.environ) rather than accepting pre-resolved secrets at construction time.
  • ConfigWalletProvider wallet cache key changed from a flat "walletId:network" string to a three-level nested Map<walletId, Map<WalletType, Map<network, Wallet>>> (TS) / tuple key (walletId, WalletType, network) (Python) to support network-agnostic Privy wallets.
  • resolveWallet() corrected to pass network when forwarding to EnvWalletProvider.getActiveWallet().
  • --network CLI flag changed from required to optional on sign-tx, sign-msg, and sign-typed-data commands.

5. Utility Extraction

Helper functions previously duplicated across cli.ts, local.ts, raw-secret.ts, and resolver.ts extracted into dedicated utility modules: core/utils/env.*, core/utils/hex.*, core/utils/keys.*, and core/utils/network.*.

6. KV Store — Test-Speed Optimization

_scrypt_params() / getScryptParams() added to detect test environments (PYTEST_CURRENT_TEST, process.env.VITEST) and use a faster N=16384 instead of N=262144. Scrypt parameters are now read back from stored keystores on decryption.

7. CLI Enhancements

  • Added privy as a selectable wallet type in start and add commands.
  • requireInteractive / _require_interactive guard added to all prompts; provides a clear error message in non-interactive environments.
  • Password prompting refactored into re-usable helper functions with retry loops.
  • cmdInspect redacts Privy credentials.

8. Tests

New test files: privy-adapter.test.ts, privy-client.test.ts, privy-config.test.ts (TS) and equivalents in Python. Existing cli.test.ts/test_cli.py extended with Privy flow coverage. Import smoke tests added.

9. Documentation / Spec Files

Large volumes of .kiro/ spec files, AGENTS.md, README.md additions, and doc/how-to-add-privy-wallet.md.


Detailed Findings

Critical

[C-01] Python urllib.request.urlopen raises HTTPError on 4xx/5xx — HTTP error handling is silently bypassed (Critical)

Property Value
Severity Critical
Category Correctness
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 65–88

Description:
urllib.request.urlopen raises urllib.error.HTTPError for any HTTP response with a status code >= 400. The current implementation reads the response body inside the with urlopen(req) block, and only checks status >= 400 after the with block exits. Because HTTPError is raised on entry, execution never reaches the status check for 4xx/5xx responses. This means:

  • 401 Unauthorized responses are never caught as PrivyAuthError.
  • 429 Too Many Requests responses are never caught for retry.
  • All 4xx/5xx errors surface as uncaught urllib.error.HTTPError, which propagates out to callers without being converted to a PrivyRequestError.

The TypeScript client uses fetch(), which never throws on HTTP errors, so this is a Python-only defect.

Code:

with urlopen(req) as response:
    status = getattr(response, "status", 0)
    payload = _read_json(response)

if status == 429:   # never reached for 4xx/5xx
    ...
if status >= 400:   # never reached for 4xx/5xx
    ...

Recommendation:
Catch urllib.error.HTTPError to extract the status code and body:

from urllib.error import HTTPError, URLError
import urllib.error

def _request(self, method, path, body=None, *, authorization_signature=None):
    attempt = 0
    while True:
        req = Request(...)
        try:
            with urlopen(req) as response:
                status = response.status
                payload = _read_json(response)
        except HTTPError as exc:
            status = exc.code
            try:
                payload = json.loads(exc.read().decode("utf-8"))
            except Exception:
                payload = {}
        if status == 429:
            ...
        if status >= 400:
            ...
        return payload

[C-02] Python PrivyAdapter_tron_sign_bytes uses tronpy.keys.hash_message while TypeScript uses keccak256; hashing semantics diverge (Critical)

Property Value
Severity Critical
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 116–122 vs packages/typescript/src/core/adapters/privy.ts : Lines 114–117

Description:
The Python implementation uses tronpy.keys.hash_message(data), which prepends \x19TRON Signed Message:\n{len} before hashing. The TypeScript implementation uses keccak256(bytes) directly without any prefix. The local TronSigner uses tron_key.sign_msg() (also uses tronpy.keys.sign_msg_hash(keccak256(data))), not hash_message.

The two Privy adapters will produce different digests for sign_message, causing incompatible signatures between the Python and TypeScript implementations and potentially against the local TronSigner baseline. The compare_sign_consistency examples in the PR exist to test cross-language compatibility, so this divergence is a regression.

Code:

# Python - WRONG: prepends TRON prefix
digest = hash_message(data)          # hash_message adds "\x19TRON Signed Message:\n"

# TypeScript - no prefix
const hashHex = keccak256(bytes)

Recommendation:
Align both to use plain keccak256 (no prefix), matching the local TronSigner.sign_raw behavior:

from eth_utils import keccak
digest = keccak(primitive=data)  # keccak256(data), no prefix

Major

[M-01] app_secret stored in plaintext in wallets_config.json (Major)

Property Value
Severity Major
Category Security
File packages/python/src/agent_wallet/core/config.py : PrivyWalletParams; packages/typescript/src/core/config.ts : PrivyWalletParamsSchema

Description:
PrivyWalletParams stores app_secret as a plain string field. The wallet config is written to ~/.agent-wallet/wallets_config.json in cleartext. The app_secret is an API key that grants signing authority over all wallets in the Privy application — this is equivalent to a private key. The existing local_secure wallet type stores the actual private key in an AES-128-CTR encrypted keystore file; raw_secret similarly stores the key in config as a plain hex string (an existing known limitation), but Privy's app_secret + wallet_id combination grants remote signing without any additional password.

Recommendation:

  1. Consider storing the app_secret using the existing SecureKVStore (same as local_secure key material) and referencing it via a secret_ref. This maintains the same security boundary as other key types.
  2. Alternatively, document clearly that the app_secret is stored in plaintext and advise using filesystem permissions or environment variable injection for production deployments.
  3. At minimum, add a visible warning during privy wallet creation in the CLI.

[M-02] Python Privy client is synchronous (time.sleep) but called from async context — will block the event loop (Major)

Property Value
Severity Major
Category Correctness / Performance
File packages/python/src/agent_wallet/core/clients/privy.py : all public methods; packages/python/src/agent_wallet/core/adapters/privy.py : all async methods

Description:
PrivyClient.get_wallet(), .rpc(), .raw_sign() and ._request() are all synchronous methods that call urllib.request.urlopen() (blocking I/O) and time.sleep() (blocking sleep) directly. They are called from async methods in PrivyAdapter without await (correctly — they are not coroutines), but since they perform blocking I/O, any use inside an asyncio event loop will block the entire loop for the duration of each HTTP request.

This is particularly harmful in an agentic environment where multiple concurrent operations may share the same event loop.

Code:

async def get_address(self) -> str:
    ...
    payload = self._client.get_wallet(self._wallet_id)  # blocks event loop

Recommendation:
Either:

  1. Make PrivyClient fully async using aiohttp or httpx[asyncio] (preferred for consistency with the TypeScript client that uses fetch).
  2. Or run blocking calls in a thread pool: await asyncio.to_thread(self._client.get_wallet, self._wallet_id).

[M-03] chainType comparison is case-sensitive in TypeScript getAddress but .toLowerCase() in getChainType — inconsistent caching (Major)

Property Value
Severity Major
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 34–39 vs Lines 97–101

Description:
getAddress() caches wallet.chainType verbatim (e.g. "ethereum" or "Ethereum"), while getChainType() stores wallet.chainType?.toLowerCase(). If getAddress() is called first, cachedChainType is set without .toLowerCase(). The subsequent call to getChainType() returns the cached value from cachedChainType without applying .toLowerCase(), so a value like "TRON" would not match the === 'tron' check.

Code:

// getAddress: stores without toLowerCase
this.cachedChainType = wallet.chainType ?? null

// getChainType: if already cached, returns without toLowerCase
if (this.cachedChainType) return this.cachedChainType

Recommendation:
Normalize casing at the point of storage in getAddress():

this.cachedChainType = wallet.chainType?.toLowerCase() ?? null

[M-04] _scrypt_params relies on PYTEST_CURRENT_TEST env var — silently uses reduced security parameters in all pytest runs (Major)

Property Value
Severity Major
Category Security / Correctness
File packages/python/src/agent_wallet/local/kv_store.py : Lines 49–58; packages/typescript/src/local/kv-store.ts : getScryptParams()

Description:
The _scrypt_params() / getScryptParams() functions automatically reduce the scrypt work factor from N=262144 to N=16384 when PYTEST_CURRENT_TEST (Python) or process.env.VITEST (TypeScript) is detected. While this is intended to speed up test execution, it creates a risk:

  1. Any production code inadvertently invoked while PYTEST_CURRENT_TEST is set (e.g. integration tests, CI environments that run pytest) will encrypt with weaker parameters. The stored keystore will then be decrypted correctly (parameters are stored in the file), but the encryption security was reduced.
  2. For TypeScript, the VITEST env var may be set in unrelated CI environments.
  3. The approach bakes test-specific behavior into production code.

Recommendation:
Use a separate injectable parameter for tests rather than probing for test runner env vars. The SecureKVStore constructor or encryptBytes/decryptBytes functions could accept an optional scryptParams override, which tests pass explicitly.


[M-05] EnvWalletResolved type in TypeScript uses a union of one — misleading type definition (Major)

Property Value
Severity Major
Category Code Quality
File packages/typescript/src/core/providers/wallet-builder.ts : Lines 47–52

Description:
The EnvWalletResolved type is declared as:

export type EnvWalletResolved =
  | {
      params: RawSecretPrivateKeyParams | RawSecretMnemonicParams
      network: string | undefined
    }

A union of one member has no practical use. The network: string | undefined also loses the guarantee from resolveNetwork() that network can be undefined when no network is provided — a subtle semantic issue as callers of createEnvAdapter pass resolved.network which is then passed to RawSecretSigner, and parseNetworkFamily(undefined) throws "network is required". This means using EnvWalletProvider without a network will throw at signing time rather than at construction time.

Recommendation:
Consider removing the union wrapper and using a plain interface, and document or enforce the network requirement earlier.


[M-06] PrivyConfigResolver in TypeScript does not read from environment variables — inconsistency with the general pattern (Major)

Property Value
Severity Major
Category Correctness / Documentation
File packages/typescript/src/core/providers/privy-config.ts

Description:
The PrivyConfigResolver.merge() only reads from this.source (the parsed config file). There is no provision for overriding app_id, app_secret, or wallet_id via environment variables (e.g. PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID). By contrast, the wallet password and wallet directory already support env var overrides. For a server-side deployment, operators may prefer to supply the Privy secret via env to avoid writing it to disk even in plaintext config. The Python implementation has the same limitation.

This is particularly important given finding [M-01] — env var support would be a mitigation for the plaintext secret storage concern.

Recommendation:
Add env var fallbacks (PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID) to merge(), document them in the README, and include them in the CLAUDE.md env vars table.


Minor

[N-01] TypeScript PrivyClient.rpc — object literal has inconsistent indentation (Minor)

Property Value
Severity Minor
Category Code Quality
File packages/typescript/src/core/clients/privy.ts : Lines 64–69

Description:
The object literal passed as body to this.request() in rpc() has the keys indented at the same level as the enclosing function call arguments, not inside the object. This may or may not cause a lint/prettier failure but is visually inconsistent with the rawSign method below it.

Code:

return this.request(
  'POST',
  `/v1/wallets/${walletId}/rpc`,
  {
  method,     // <-- should be indented two more spaces
  params,
  },
  options,
) as Promise<PrivyRpcResponse>

Recommendation:

  {
    method,
    params,
  },

[N-02] toHexValue / _to_hex_value — negative integers produce incorrect hex in Python (Minor)

Property Value
Severity Minor
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 222–234

Description:
In Python, hex(-1) returns '-0x1', so passing a negative integer value to _to_hex_value would produce '-0x1' as the transaction field value instead of raising an error. This is unlikely in practice for gas/fee fields but is a silent corruption bug.

Recommendation:
Add a guard for negative integers:

if isinstance(value, int):
    if value < 0:
        raise ValueError(f"Expected non-negative integer for hex conversion, got {value}")
    return hex(value)

[N-03] tronSignBytes in TypeScript and Python use different hashing (keccak vs hash_message) — inconsistency with local TronSigner (Minor)

(This is a duplicate of finding [C-02] but noted here for the TypeScript side as a lesser concern.)

The TypeScript PrivyAdapter.tronSignBytes applies keccak256(bytes) before signing. The Python local TronSigner.sign_message also applies keccak256 via tron_key.sign_msg() (which internally calls sign_msg_hash(keccak(data))). This part is consistent. The Python Privy adapter is the outlier.


[N-04] normalizeTypedDataPayload mutates input dict in Python (Minor)

Property Value
Severity Minor
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 237–242

Description:
_normalize_typed_data_payload mutates the typed dict in place by calling typed.pop("primaryType"). If the caller reuses the same payload dict after calling this function, the primaryType key will be unexpectedly absent.

Code:

if isinstance(typed, dict) and "primaryType" in typed and "primary_type" not in typed:
    typed["primary_type"] = typed.pop("primaryType")  # mutates caller's dict

Similarly in _tron_sign_typed_data, the normalized payload is mutated.

Recommendation:
Work on a copy: typed = dict(typed) before mutation, or use a non-mutating approach.


[N-05] buildPrivyConfig in TypeScript CLI reuses app credentials from existing wallet — app_secret reuse path leaks secret from config (Minor)

Property Value
Severity Minor
Category Security
File packages/typescript/src/delivery/cli.ts : Lines 664–697

Description:
When adding a second Privy wallet that reuses credentials from an existing one, buildPrivyConfig reads the app_secret from the existing PrivyWalletParams config and embeds it in the new wallet config. This is the intended behavior, but it means the secret is passed through multiple layers in memory without zeroing. This is a low-severity concern given that it is already stored on disk, but should be noted.


[N-06] recoverTronRecoveryId silently swallows all exceptions in recovery loop (Minor)

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 278–290; packages/python/src/agent_wallet/core/adapters/privy.py : Lines 258–272

Description:
The recovery loop catches all exceptions with a bare except (Python) / catch {} (TypeScript). If secp256k1.Signature.fromCompact() fails due to a malformed signature (not a recovery failure), the exception is silently consumed and both recovery bits are tried, ultimately leading to UnsupportedOperationError rather than a more descriptive error.

Recommendation:
Narrow the catch to ValueError / secp256k1-specific exceptions and let unexpected errors propagate.


[N-07] doc/how-to-add-privy-wallet.md omits warning about plaintext app_secret storage (Minor)

Property Value
Severity Minor
Category Documentation
File doc/how-to-add-privy-wallet.md

Description:
The how-to guide describes adding a Privy wallet but does not warn users that app_secret is stored in plaintext in wallets_config.json. This is a security-relevant omission that could lead users to inadvertently commit or expose the file containing their API secret.


[N-08] EnvWalletProvider constructor signature change is a breaking API change (Minor)

Property Value
Severity Minor
Category Code Quality / Compatibility
File packages/typescript/src/core/providers/env-provider.ts; packages/python/src/agent_wallet/core/providers/env_provider.py

Description:
EnvWalletProvider previously accepted privateKey, mnemonic, and accountIndex constructor parameters, allowing callers to inject secrets directly. These parameters have been removed. Any external code (e.g. integrations that build EnvWalletProvider directly) that passed these parameters will break silently at runtime in Python (no TypeError for keyword-only args in most cases) or fail at compile time in TypeScript.

The public API exports EnvWalletProvider via src/index.ts, making this a public breaking change.


[N-09] _scrypt_params catches ValueError on invalid AGENT_WALLET_TEST_SCRYPT_N but falls through to a separate PYTEST_CURRENT_TEST check instead of erroring (Minor)

Property Value
Severity Minor
Category Correctness
File packages/python/src/agent_wallet/local/kv_store.py : Lines 49–58

Description:
If AGENT_WALLET_TEST_SCRYPT_N is set to an invalid (non-integer) string, the except ValueError block silently sets n = DEFAULT_SCRYPT_N and then falls through to the PYTEST_CURRENT_TEST check, which could return the low-N test params. The original intent was to allow AGENT_WALLET_TEST_SCRYPT_N to override even in test contexts, but the silent fallback makes the behavior confusing.


Suggestions

[S-01] Consider making PrivyClient injectable via interface rather than concrete class in adapters

Having PrivyClient as a concrete dependency of PrivyAdapter (constructor parameter) is good. However, there is no abstract interface or protocol for PrivyClient that would allow easier mocking in unit tests beyond the ad-hoc FakePrivyClient in test files. Introducing a PrivyClientProtocol (Python) / interface (TypeScript) would formalize the contract.

[S-02] normalizeTransactionPayload — the pick() helper returns undefined if no keys match, and assign() skips undefined, but null values are passed through

If a caller explicitly passes { value: null }, assign('value', null) would skip it (correct for Privy's API). This is intended behavior but should be documented or verified against Privy's API spec.

[S-03] Retry backoff for rate limiting is very aggressive

Max backoff is 1 second with 2 retries (200ms * 1, 200ms * 2 = 400ms, max 1s). Privy's rate limits may be strict enough that a longer backoff (e.g. exponential starting at 1s) would be more effective.

[S-04] Test fixtures using session-scoped template dirs (initialized_template_dir, signer_template_dir)

The session-scoped fixtures in Python tests/test_cli.py that copy a pre-initialized template are a good optimization. However, the template setup uses a module-level monkeypatch in a session fixture, which is not the recommended pattern. Consider using pytest.MonkeyPatch() as a context manager instead of monkeypatch.undo() in a finally block.


Positive Observations

  1. Dual-language parity is excellent. The Python and TypeScript implementations mirror each other closely in structure and behavior. The new utility modules (core/utils/) properly eliminate the duplicated helper functions that existed before this PR.

  2. SignOptions design is clean. The optional authorizationSignature parameter threads through all signing methods without breaking the existing Wallet interface for non-Privy adapters. The existing adapters correctly accept and ignore the option parameter.

  3. Recovery-bit computation for TRON is correct. The approach of trying both v=0 and v=1, recovering the public key, deriving the Tron address, and comparing against the cached address is the right approach for a raw_sign API that returns 64-byte r||s without the recovery bit.

  4. Cache invalidation on wallet-info fetch is appropriately minimal. Both getAddress() and getChainType() share the same cache, preventing double-fetches. The lazy initialization pattern is appropriate.

  5. Non-interactive environment detection (requireInteractive / _require_interactive) is a useful improvement that provides clear error messages rather than hanging on TTY prompts.

  6. Scrypt parameters are now stored and read back from the keystore, ensuring that wallets encrypted with test-mode parameters (N=16384) can always be decrypted correctly even if the test environment flag is not set at decryption time.

  7. Error hierarchy (PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError) is well-designed and allows callers to handle specific Privy failure modes.

  8. Test coverage is comprehensive. New test files cover happy paths and edge cases for the Privy adapter, client, and config resolver. The existing CLI test suite is extended with Privy-specific flows.


Review Verdict

Request Changes

Must fix before merge:

  • [C-01] Python urllib.request.urlopen raises HTTPError on 4xx/5xx — HTTP error responses are never handled correctly. Rate limiting, auth failures, and request errors are all silently propagated as uncaught urllib.error.HTTPError instead of the typed Privy*Error classes.
  • [C-02] Python _tron_sign_bytes uses tronpy.keys.hash_message (adds TRON-prefix) while TypeScript uses plain keccak256 — produces incompatible signatures for TRON message signing across the two implementations.
  • [M-01] app_secret stored in plaintext in wallets_config.json — at minimum, a CLI warning and documentation note are required.
  • [M-02] Python Privy HTTP client is synchronous (blocking urlopen + time.sleep) called from async context — will block the asyncio event loop in practice.
  • [M-03] TypeScript cachedChainType populated without .toLowerCase() in getAddress() — can cause TRON routing to fail silently.

Should fix before merge:

  • [M-04] Test-environment detection baked into production _scrypt_params().
  • [M-06] No env var override support for Privy credentials.
  • [N-08] Breaking change to EnvWalletProvider public constructor API.

@github-actions
Copy link
Copy Markdown

Audit Report — PR: main → feat/privy-support

Repository: agent-wallet
Base branch: remotes/origin/main
Head branch: remotes/origin/feat/privy-support
Review date: 2026-03-31
Reviewer: Claude Code (claude-sonnet-4-6)


1. PR Overview

Item Value
Commits 4
Files changed 116
Lines added ~9,665
Lines removed ~916

The PR introduces Privy API-backed wallet support to both TypeScript and Python packages. It also adds:

  • A resolve-address CLI command
  • Hierarchical CLI command refactoring (the start and add sub-commands)
  • Shared utility modules (utils/env, utils/hex, utils/keys, utils/network)
  • A new SignOptions type threaded through all Wallet interface methods
  • A SecureKVStore credential storage API (loadCredential / saveCredential)
  • Extensive new tests

2. Commit History

Hash Message
502b4ed feat: resolve-address command
c287ebf feat: resolve address
5f58664 feat: optimize hierarchical command and optimize remove command ux
c4fba9a feat: privy support

3. Change Summary

3.1 New core components

  • PrivyAdapter (TS: core/adapters/privy.ts, Python: core/adapters/privy.py): Implements the full Wallet + Eip712Capable interface by proxying to the Privy REST API. Handles EVM and TRON chains, including TRON recovery-bit computation from raw_sign.
  • PrivyClient (TS: core/clients/privy.ts, Python: core/clients/privy.py): Thin HTTP client for the Privy API (/v1/wallets/:id, /v1/wallets/:id/rpc, /v1/wallets/:id/raw_sign). Supports basic-auth, exponential backoff on 429.
  • PrivyConfigResolver (TS: core/providers/privy-config.ts, Python: core/providers/privy_config.py): Validates and merges app_id, app_secret, wallet_id config fields.

3.2 Interface changes

  • Wallet.signRaw/signTransaction/signMessage and Eip712Capable.signTypedData now accept an optional SignOptions parameter ({ authorizationSignature? }) — backward-compatible.
  • resolveNetwork() now returns string | undefined instead of throwing when neither explicit nor default is provided — silently propagates undetermined network.

3.3 Provider and resolver updates

  • ConfigWalletProvider wallet cache refactored: from a flat Map<string, Wallet> to a nested Map<walletId → Map<type → Map<network | undefined → Wallet>>> to accommodate type-aware caching for Privy wallets.
  • EnvWalletProvider constructor simplified; env-key parsing moved into wallet-builder.
  • WalletType.PRIVY added throughout config schemas (Zod / Pydantic).

3.4 Utility modules extracted

  • core/utils/env.ts|pycleanEnvValue, firstEnv, parseAccountIndex
  • core/utils/hex.ts|pystripHexPrefix
  • core/utils/keys.ts|pydecodePrivateKey, deriveKeyFromMnemonic
  • core/utils/network.ts|pyresolveNetwork, parseNetworkFamily

3.5 Address resolution

  • New resolveWalletAddresses() function + resolve-address CLI command returns per-format (EVM / TRON) addresses for all wallet types.

3.6 CLI enhancements

  • add privy / start privy sub-commands with interactive credential prompting.
  • inspect command shows Privy wallet info with redacted app_secret.
  • remove UX improved.
  • resolve-address command.

3.7 Storage

  • SecureKVStore extended with loadCredential / saveCredential for encrypted JSON/string storage.

4. Detailed Findings

Critical

C-01 — Correctness | Python remove_wallet cache eviction silently fails

File: packages/python/src/agent_wallet/core/providers/config_provider.py lines 99–103
Severity: Critical

The wallet cache self._wallets uses tuple keys of the form (wallet_id, WalletType, network | None). The eviction filter calls .startswith() on these tuple keys, which raises AttributeError at runtime whenever a wallet has been cached before removal is called.

# cache_key is a tuple, e.g. ("my_wallet", WalletType.LOCAL_SECURE, "eip155")
self._wallets = {
    cache_key: wallet
    for cache_key, wallet in self._wallets.items()
    if not cache_key.startswith(f"{wallet_id}:")   # <-- AttributeError: 'tuple' has no attribute 'startswith'
}

Recommendation: Change the filter to match on the first element of the tuple:

if cache_key[0] != wallet_id

C-02 — Security | Privy app_secret stored in plaintext in wallets_config.json

File: packages/typescript/src/core/config.ts (schema), packages/typescript/src/delivery/cli.ts lines 712–718, documentation doc/how-to-add-privy-wallet.md line 105
Severity: Critical

The Privy app_secret (a server-side credential with signing authority over all wallets in a Privy app) is stored in plain JSON in wallets_config.json. The documentation explicitly acknowledges this ("The app secret is stored in wallets_config.json"). Unlike private keys, this secret has no per-wallet granularity; leaking it compromises all wallets associated with the Privy app.

This is especially dangerous because wallets_config.json is not Keystore-encrypted — only secret_*.json files are. The inspect command redacts it on display, but the underlying file remains plaintext.

Recommendation: Encrypt app_secret using SecureKVStore (same AES-128-CTR + scrypt mechanism used for private keys), referencing it by a stored credential name rather than embedding it directly. Alternatively, document that wallets_config.json must be treated as a secret and restrict its permissions to 0o600 (currently the config file may not be chmodded).


Major

MJ-01 — Correctness | Python PrivyClient is synchronous but PrivyAdapter is async

File: packages/python/src/agent_wallet/core/clients/privy.py lines 29–89, packages/python/src/agent_wallet/core/adapters/privy.py lines 33–153
Severity: Major

PrivyClient._request uses urllib.request.urlopen (blocking I/O). The PrivyAdapter methods are declared async and call self._client.get_wallet(...) and self._client.rpc(...) without await. In a running asyncio event loop, this blocks the entire event loop for the duration of each HTTP round-trip, negating the benefit of async and causing latency spikes.

# privy.py — async method calling blocking HTTP synchronously
async def get_address(self) -> str:
    if self._cached_address:
        return self._cached_address
    payload = self._client.get_wallet(self._wallet_id)  # blocking, no await

Recommendation: Either wrap the sync calls with asyncio.get_event_loop().run_in_executor(None, ...), or replace urllib with an async HTTP library (httpx / aiohttp) in PrivyClient.


MJ-02 — Security | app_secret transmitted in Basic Auth over every HTTP request without TLS verification enforcement

File: packages/python/src/agent_wallet/core/clients/privy.py lines 66–71
Severity: Major

The Python PrivyClient uses urllib.request.urlopen without explicitly creating an SSL context that enforces certificate validation. While Python 3.x defaults to verifying certificates via the system trust store, there is no pinning or explicit ssl.create_default_context() call. Combined with the app_secret being in the Authorization header in every request, a MITM or misconfigured proxy could intercept credentials.

Recommendation: Use ssl.create_default_context() explicitly and pass it via urllib.request.urlopen(req, context=ctx) to make TLS enforcement explicit and auditable.


MJ-03 — Correctness | resolveNetwork now returns undefined instead of throwing — silent failure for non-Privy wallets

File: packages/typescript/src/core/utils/network.ts lines 11–18, previously in core/providers/config-provider.ts
Severity: Major

The old resolveNetwork threw Error('network is required') when neither explicit nor default network was provided. The new version silently returns undefined. This return value is now passed to RawSecretSigner as network?: string | undefined, which may later throw a less informative error or behave unexpectedly when network-specific paths (EVM vs TRON) are needed.

// Old (throws):
function resolveNetwork(explicit, providerDefault): string {
  if (explicit) return explicit
  if (providerDefault) return providerDefault
  throw new Error('network is required')
}

// New (silent):
export function resolveNetwork(...): string | undefined {
  if (explicit) return explicit
  if (providerDefault) return providerDefault
  return undefined   // downstream code may silently break
}

Recommendation: Either restore the throwing behavior for non-Privy wallet types in the call sites, or add an assertion in RawSecretSigner constructor when network is undefined.


MJ-04 — Correctness | Race condition in PrivyAdapter address/chain-type cache

File: packages/typescript/src/core/adapters/privy.ts lines 33–108
Severity: Major

getAddress() and getChainType() each independently fetch the wallet, and each sets both cachedAddress and cachedChainType. However, getAddress() sets cachedChainType = wallet.chainType ?? null (preserves case), while getChainType() sets cachedChainType = wallet.chainType?.toLowerCase() ?? ''. If getAddress() is called first and cachedChainType is set to a mixed-case string (e.g., "Tron"), subsequent calls to getChainType() return the cached non-normalized value, causing chain detection to fail.

// getAddress — stores raw case:
this.cachedChainType = wallet.chainType ?? null  // e.g., "Tron" or "Ethereum"

// getChainType — checks cache first, then normalizes:
if (this.cachedChainType) return this.cachedChainType  // returns "Tron", not "tron"

Chain comparisons downstream use === 'tron' (lowercase), so this can cause EVM fallback for TRON wallets.

Recommendation: Normalize cachedChainType to lowercase at both assignment sites in getAddress().


MJ-05 — Correctness | Python PrivyAdapter._tron_sign_bytes uses tronpy.keys.hash_message instead of keccak256

File: packages/python/src/agent_wallet/core/adapters/privy.py lines 116–122
Severity: Major

The TypeScript implementation hashes raw bytes with keccak256 before signing (consistent with Ethereum's raw signing convention). The Python implementation uses tronpy.keys.hash_message, which applies the TRON message prefix \x19TRON Signed Message:\n{len} before hashing. This produces a different digest than the TypeScript implementation, breaking cross-language consistency for signRaw on TRON wallets.

# Python (adds TRON prefix)
async def _tron_sign_bytes(self, data: bytes, ...) -> str:
    from tronpy.keys import hash_message
    digest = hash_message(data)   # prepends TRON prefix

# TypeScript (pure keccak256)
private async tronSignBytes(bytes: Uint8Array, ...): Promise<string> {
    const hashHex = keccak256(bytes)   // no prefix

The project explicitly includes a compare_sign_consistency example, making this discrepancy particularly visible.

Recommendation: Align both implementations. Use bare keccak256 in Python for sign_raw (matching TypeScript), or document the intentional divergence with a test that validates both produce the same hash.


Minor

MN-01 — Code Quality | normalizeTypedDataPayload mutates its input

File: packages/typescript/src/core/adapters/privy.ts lines 257–266
Severity: Minor

The function mutates the typed_data object in place (typed.primary_type = ...; delete typed.primaryType). Since data comes from the caller, this is an unexpected side effect that may corrupt the caller's data.

function normalizeTypedDataPayload(data: Record<string, unknown>) {
  // ...
  if ('primaryType' in typed && !('primary_type' in typed)) {
    typed.primary_type = typed.primaryType   // mutates caller's object
    delete typed.primaryType
  }

Recommendation: Clone typed before mutating: const typed = { ...(payload.typed_data as Record<string, unknown>) }.


MN-02 — Code Quality | backoffMs starts at 200ms for attempt=1, zero delay on first retry

File: packages/typescript/src/core/clients/privy.ts lines 143–144
Severity: Minor

The backoff formula Math.min(1000, 200 * attempt) returns 200ms for attempt=1. However, for attempt=0 (before increment), there is no sleep at all because the code only calls this.sleep after incrementing. The effective backoff sequence is: no wait → 200ms → 400ms → ... capped at 1000ms. The Python client uses 0.2 * attempt (seconds) with the same structure. This is acceptable but not documented.


MN-03 — Code Quality | PrivyClient.rpc has inconsistent indentation in object literal

File: packages/typescript/src/core/clients/privy.ts lines 62–70
Severity: Minor

The object body passed to this.request has misaligned indentation (the method and params keys are indented 6 spaces instead of 8):

return this.request(
  'POST',
  `/v1/wallets/${walletId}/rpc`,
  {
  method,      // <-- 2 spaces instead of 4 inside object
  params,
  },
  options,

MN-04 — Documentation | wallets_config.json schema does not warn about plaintext app_secret

File: doc/how-to-add-privy-wallet.md lines 104–105, README.md
Severity: Minor

The FAQ section states "The app secret is stored in wallets_config.json (config provider). inspect redacts it." but does not warn users that this means the file should be treated as a secret, that it should not be committed to version control, and that it is not encrypted at rest.

Recommendation: Add a clear security warning in the documentation and consider adding wallets_config.json to .gitignore examples.


MN-05 — Correctness | tronAddressFromPublicKey drops the leading 04 uncompressed marker unconditionally

File: packages/typescript/src/core/adapters/privy.ts lines 309–314
Severity: Minor

function tronAddressFromPublicKey(pubkey: Uint8Array): string {
  const uncompressed = pubkey[0] === 4 ? pubkey.slice(1) : pubkey

pub.toRawBytes(false) always returns the 65-byte uncompressed form (04 || x || y). The branch pubkey[0] === 4 will always be true, making the else branch dead code. While this works correctly, it is misleading.


MN-06 — Testing | FakePrivyClient in tests is not async (returns values, not Promises) but the real PrivyClient is async

File: packages/typescript/tests/privy-adapter.test.ts lines 36–61
Severity: Minor

The fake client methods (getWallet, rpc, rawSign) are declared async but the real PrivyClient method types are Promise<...>. This works at runtime because async functions return Promises, but the fake client is tightly coupled to the test file rather than to a shared interface. A type mismatch between PrivyClient and the test double would not be caught.

Recommendation: Extract a PrivyClientLike interface and use it as the type for the client parameter in PrivyAdapter, then type FakePrivyClient against that interface.


MN-07 — Code Quality | Python _recover_tron_v catches bare Exception silently

File: packages/python/src/agent_wallet/core/adapters/privy.py lines 260–270
Severity: Minor

for v in (0, 1):
    try:
        ...
    except Exception:   # swallows all exceptions including programming errors
        continue

A ValueError or TypeError from a programming mistake would be silently swallowed, making debugging very difficult.

Recommendation: Catch only the specific cryptographic exceptions (ValueError, AssertionError) that tronpy.keys can raise during recovery, not bare Exception.


MN-08 — Correctness | resolveWalletAddresses calls new EvmSigner / new TronSigner directly bypassing the adapter abstraction

File: packages/typescript/src/core/address-resolution.ts lines 47–63
Severity: Minor

The resolveWalletAddresses function for local_secure and raw_secret wallets instantiates EvmSigner and TronSigner directly, bypassing LocalSigner / LocalSecureSigner. This duplicates key-loading logic and means future changes to those signers would not automatically be reflected in address resolution.


Suggestions

S-01 — Code Quality | PrivyConfigResolver.isEnabled() calls merge() and resolve() each call both merge() separately

File: packages/typescript/src/core/providers/privy-config.ts lines 24–43
Severity: Suggestion

isEnabled() and resolve() each independently call this.merge(), which re-constructs and normalizes the config on each invocation. Since PrivyConfigResolver is constructed with immutable source data, memoize merge() result.


S-02 — Code Quality | EnvWalletResolved is a union with only one member

File: packages/typescript/src/core/providers/wallet-builder.ts lines 984–989
Severity: Suggestion

export type EnvWalletResolved =
  | {
      params: RawSecretPrivateKeyParams | RawSecretMnemonicParams
      network: string | undefined
    }

The | union with a single member adds no value. Use a plain object type directly.


S-03 — Performance | PrivyAdapter.getChainType() may make a second network call if getAddress() was already called

File: packages/typescript/src/core/adapters/privy.ts lines 96–108
Severity: Suggestion

The only reason getChainType() might make a second call is if cachedChainType is falsy due to the case normalization issue described in MJ-04. Resolving MJ-04 would also eliminate this redundant call.


S-04 — Documentation | No migration note for the Wallet interface change (SignOptions parameter)

File: packages/typescript/src/core/base.ts lines 349–352
Severity: Suggestion

The Wallet interface methods now have an additional optional parameter. While backward-compatible for callers, existing custom Wallet implementations will have TypeScript type errors if they don't add the optional parameter. A brief migration note in CHANGELOG or README would help consumers.


5. Positive Observations

  • Clean architecture separation: PrivyClient (HTTP), PrivyAdapter (signing logic), and PrivyConfigResolver (config validation) are properly separated with clear responsibilities. Dependency injection is used throughout (client is injected, not hard-constructed).
  • Robust TRON recovery-bit logic: The approach of trying both recovery values (0, 1) and validating against the known address is correct and well-tested. The TypeScript implementation uses @noble/curves correctly.
  • Good error taxonomy: Privy-specific errors (PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError) extend WalletError and are exported from the public API.
  • Transaction payload normalization is thorough: normalizeTransactionPayload handles both snake_case and camelCase field names and converts numeric fields to hex correctly. Integer overflow is handled via BigInt.
  • Retry with backoff on 429: Both TS and Python clients implement configurable retry with injectable sleep functions, making them fully testable.
  • Test coverage is substantial: New tests in privy-adapter.test.ts, privy-client.test.ts, privy-config.test.ts cover the happy path and major error cases. Python mirrors this coverage.
  • app_secret is redacted in inspect output: The CLI correctly avoids printing the secret during inspect, showing *** instead.
  • SignOptions.authorizationSignature is properly threaded: The authorization signature is passed all the way from the public API down to the HTTP request header, enabling Privy server-side authorization signing.
  • Dual-language parity maintained: All new core components exist in both TypeScript and Python with equivalent behavior (modulo MJ-05 noted above).

6. Checklist Results

Category Status Notes
Correctness Needs Work C-01 (crash), MJ-03 (silent failure), MJ-04 (cache inconsistency), MJ-05 (hash mismatch)
Security Needs Work C-02 (plaintext secret at rest), MJ-02 (no explicit TLS enforcement in Python)
Performance Needs Work MJ-01 (blocking I/O in async Python code)
Code Quality Acceptable MN-01 (mutation), MN-03 (indentation), MN-07 (broad exception catch)
Testing Acceptable Good coverage; MN-06 (untyped fake client)
Documentation Acceptable MN-04 (missing security warning)
Compatibility Pass SignOptions parameter is optional; backward-compatible
Observability Pass Error messages are descriptive; error types are granular

7. Review Verdict

Request Changes

The PR introduces a well-designed and well-tested Privy integration. However, two issues block approval:

  1. C-01 is a crash bug: remove_wallet() will raise AttributeError in Python whenever a wallet has been cached, which is the normal operating state. This is a regression against existing wallet management.

  2. C-02 is a design-level security concern: storing the Privy app_secret (a server-side credential with authority over all wallets in a Privy application) in plaintext JSON contradicts the project's "secure signing SDK" identity. This is particularly severe because the existing keystore mechanism is available and well-tested.

  3. MJ-01 (blocking I/O in an async Python context) and MJ-04 (cache normalization bug causing chain mis-detection) should also be addressed before merge.

After fixing C-01, C-02, MJ-01, and MJ-04, the PR would be in good shape to approve.

@github-actions
Copy link
Copy Markdown

Code Review Report

Project: agent-wallet (@bankofai/agent-wallet / bankofai-agent-wallet)
PR: mainfeat/privy-support
Review Date: 2026-03-31
Reviewer: AI Code Reviewer (Code Review Skill v1.0.0)


PR Overview

Branch Information

Property Value
From Branch main
To Branch feat/privy-support
Commits 5
Files Changed 116
Lines Added +9,693
Lines Removed -921

Commit History

Hash Message
9bfe661 feat: change-password no interact mode
502b4ed feat: resolve-address command
c287ebf feat: resolve address
5f58664 feat: optimize hierarchical command and optimize remove command ux
c4fba9a feat: privy support

Review Summary

Verdict

Verdict: Request Changes

Findings at a Glance

Critical Major Minor Suggestion
Count 1 4 5 4

Summary

This PR introduces Privy-backed wallet support across both TypeScript and Python implementations, adding a third wallet type (privy) alongside local_secure and raw_secret. The work is substantial: new PrivyAdapter, PrivyClient, PrivyConfigResolver classes, a SignOptions interface propagated throughout the signing chain, address resolution utilities, and several refactors (env-var constants moved to base, utility modules extracted, cache key strategy improved). The design mirrors the existing dual-language pattern and is architecturally sound.

However, there is one critical bug that must be fixed before merge: the Python PrivyClient uses urllib.request.urlopen, which raises urllib.error.HTTPError for all 4xx/5xx responses — making the entire retry-on-429 and error-classification logic dead code. There are also four major issues: the Privy app_secret is stored in cleartext config (unlike private keys protected by Keystore V3), a v-byte inconsistency in TRON signing between the Python and TypeScript Privy adapters, the Python client's blocking I/O inside async adapter methods (event-loop stall risk), and the absence of HTTP request timeouts in the Python client. These should be resolved before shipping.


Change Summary

1. Privy Wallet Adapter (Core Feature)

File Change Type Description
packages/typescript/src/core/adapters/privy.ts Added PrivyAdapter: signs via Privy API for EVM and TRON
packages/python/src/agent_wallet/core/adapters/privy.py Added Python counterpart
packages/typescript/src/core/clients/privy.ts Added HTTP client wrapping Privy REST API (fetch-based)
packages/python/src/agent_wallet/core/clients/privy.py Added Python HTTP client (urllib-based, synchronous)
packages/typescript/src/core/providers/privy-config.ts Added Config resolver & validation
packages/python/src/agent_wallet/core/providers/privy_config.py Added Python counterpart

Purpose: Enables AI agents to sign with wallets hosted by Privy without holding private key material locally.


2. SignOptions Interface Propagation

File Change Type Description
packages/typescript/src/core/base.ts Modified Added SignOptions type; authorizationSignature? field
packages/python/src/agent_wallet/core/base.py Modified Added SignOptions dataclass; propagated to all abstract methods
packages/*/core/adapters/evm.*, tron.*, local.*, local_secure.*, raw_secret.* Modified Updated signatures to accept optional SignOptions

Purpose: Allows callers to pass a Privy authorization signature when the Privy wallet requires server-side authorization checks.


3. WalletType & Config Extended for Privy

File Change Type Description
packages/typescript/src/core/base.ts Modified Added WalletType.PRIVY = 'privy'
packages/python/src/agent_wallet/core/base.py Modified Added WalletType.PRIVY
packages/typescript/src/core/config.ts Modified PrivyWalletParamsSchema added; WalletConfigSchema union extended
packages/python/src/agent_wallet/core/config.py Modified PrivyWalletParams model; validator extended
packages/*/core/providers/wallet-builder.* Modified createAdapter/create_adapter handles privy type

Purpose: Privy wallets participate in all existing config-driven wallet lifecycle (add, list, sign, remove).


4. Provider Refactors

File Change Type Description
packages/*/core/providers/env-provider.* Modified EnvWalletProvider reads env map lazily; getWalletgetActiveWallet alignment
packages/*/core/providers/config-provider.* Modified Cache key changed from "id:network" string to (id, type, network) tuple; Privy wallets skip network resolution
packages/*/core/resolver.* Modified Env constants moved to base; refactored to use shared utilities

Purpose: Privy wallets are network-agnostic (address comes from Privy API, not derivation), so the cache and network-resolution paths were updated to handle undefined network.


5. Utility Modules Extracted

File Change Type Description
packages/typescript/src/core/utils/env.ts Added cleanEnvValue, firstEnv, parseAccountIndex
packages/typescript/src/core/utils/hex.ts Added stripHexPrefix
packages/typescript/src/core/utils/keys.ts Added decodePrivateKey, deriveKeyFromMnemonic
packages/typescript/src/core/utils/network.ts Added parseNetworkFamily, resolveNetwork
packages/python/src/agent_wallet/core/utils/env.py Added Python counterparts
packages/python/src/agent_wallet/core/utils/network.py Added Python counterparts
packages/python/src/agent_wallet/core/utils/keys.py Added Python counterparts (from wallet_builder.py)

Purpose: Deduplication — these were scattered across resolver, local.ts, raw-secret.ts, wallet_builder.py, reducing import coupling.


6. Address Resolution

File Change Type Description
packages/typescript/src/core/address-resolution.ts Added Resolves EVM+TRON addresses for local wallets, single address for Privy
packages/python/src/agent_wallet/core/address_resolution.py Added Python counterpart

Purpose: Powers the new CLI resolve-address command; Privy returns one canonical address, local wallets return both EVM and TRON addresses.


7. KV-Store: Test-Mode Scrypt Parameters

File Change Type Description
packages/typescript/src/local/kv-store.ts Modified Adds getScryptParams() that lowers N in VITEST / when AGENT_WALLET_TEST_SCRYPT_N is set
packages/python/src/agent_wallet/local/kv_store.py Modified Same; uses PYTEST_CURRENT_TEST env var instead

Purpose: Speed up test runs that exercise encryption without weakening production defaults.


Detailed Findings


Critical

[C-01] Python PrivyClient Error Handling and Retry Logic Are Unreachable

Property Value
Severity Critical
Category Correctness
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 63–89

Description

urllib.request.urlopen raises urllib.error.HTTPError for all HTTP error responses (4xx, 5xx). HTTPError is an exception, so control never reaches the if status == 429 or if status >= 400 branches that appear after the with urlopen(req) as response: block. In practice:

  • Rate-limit retry (429) will never trigger — the HTTPError propagates up unhandled.
  • Auth errors (401, 403) will surface as a raw urllib.error.HTTPError instead of PrivyAuthError.
  • General request errors (4xx/5xx) will be an uncaught HTTPError instead of PrivyRequestError.

The TypeScript client uses the fetch API, which does not throw for non-2xx responses; the Python port incorrectly assumed urlopen behaves the same way.

Code

# privy.py – Lines 63–89
def _request(self, method, path, body=None, *, authorization_signature=None):
    attempt = 0
    while True:
        req = Request(
            f"{self._base_url}{path}",
            method=method,
            headers=self._headers(authorization_signature),
            data=json.dumps(body).encode("utf-8") if body else None,
        )
        # urlopen RAISES HTTPError for 4xx/5xx — the with-block body is never entered for errors
        with urlopen(req) as response:
            status = getattr(response, "status", 0)
            payload = _read_json(response)

        if status == 429:          # ← DEAD CODE for 429
            if attempt >= self._retries:
                raise PrivyRateLimitError("Privy rate limit exceeded")
            attempt += 1
            self._sleep(_backoff_seconds(attempt))
            continue

        if status >= 400:          # ← DEAD CODE for 4xx/5xx
            message = _extract_error(payload) or f"Privy request failed with status {status}"
            if status in (401, 403):
                raise PrivyAuthError(message)
            raise PrivyRequestError(message)

        return payload

Recommendation

Catch urllib.error.HTTPError explicitly and re-use its status code and body:

from urllib.error import HTTPError, URLError

def _request(self, method, path, body=None, *, authorization_signature=None):
    attempt = 0
    while True:
        req = Request(
            f"{self._base_url}{path}",
            method=method,
            headers=self._headers(authorization_signature),
            data=json.dumps(body).encode("utf-8") if body else None,
        )
        try:
            with urlopen(req, timeout=30) as response:
                status = response.status
                payload = _read_json(response)
        except HTTPError as exc:
            status = exc.code
            try:
                payload = json.loads(exc.read().decode("utf-8"))
            except Exception:
                payload = {}
        except URLError as exc:
            raise PrivyRequestError(f"Privy request network error: {exc}") from exc

        if status == 429:
            if attempt >= self._retries:
                raise PrivyRateLimitError("Privy rate limit exceeded")
            attempt += 1
            self._sleep(_backoff_seconds(attempt))
            continue

        if status >= 400:
            message = _extract_error(payload) or f"Privy request failed with status {status}"
            if status in (401, 403):
                raise PrivyAuthError(message)
            raise PrivyRequestError(message)

        return payload

Major

[MJ-01] Privy app_secret Stored in Cleartext in wallets_config.json

Property Value
Severity Major
Category Security
File packages/typescript/src/core/config.ts : Lines 43–48 / packages/python/src/agent_wallet/core/config.py : Lines 42–48

Description

PrivyWalletParams stores app_secret as a plain string that is written directly into ~/.agent-wallet/wallets_config.json. The existing local_secure wallet type protects private keys with Keystore V3 (scrypt + AES-128-CTR). The new Privy wallet type stores its credential with no encryption. If the config file is accessed by a malicious process, the app secret is immediately readable and can be used to sign any transaction on behalf of every wallet under that Privy app.

Code

// config.ts
export const PrivyWalletParamsSchema = z.object({
  app_id: z.string(),
  app_secret: z.string(),   // ← stored verbatim in wallets_config.json
  wallet_id: z.string(),
})

Recommendation

At minimum, document clearly that app_secret is stored unencrypted and recommend users restrict file permissions (chmod 600). For a stronger fix, encrypt app_secret via the existing KV-store (Keystore V3) using the user's master password — identical to how local_secure stores private keys. The wallets_config.json would then store only a secret_ref for the Privy credentials, consistent with the existing architecture.


[MJ-02] TRON Signature v-Byte Inconsistency Between Python and TypeScript Privy Adapters

Property Value
Severity Major
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 133–138 vs packages/python/src/agent_wallet/core/adapters/privy.py : Lines 148–151

Description

When the Privy adapter signs a TRON transaction/message, the final signature byte (v) is computed inconsistently:

  • TypeScript: adds 27 to the recovery bit → appends 0x1b or 0x1c
  • Python: appends the raw recovery bit → appends 0x00 or 0x01

The local TronSigner in TypeScript also adds 27 (sig.recovery + 27). Broadcasting a signature with the wrong v-byte to TronGrid will cause the transaction to be rejected or replayed incorrectly. One of the two implementations produces invalid TRON signatures.

Code

// privy.ts – tronSignHash  (TypeScript)
const v = await recoverTronRecoveryId(sigBytes, hash, await this.getAddress())
const vHex = (v + 27).toString(16).padStart(2, '0')   // → "1b" or "1c"
return `${sigHex}${vHex}`
# privy.py – _tron_sign_hash  (Python)
v = _recover_tron_v(sig_bytes, digest, await self.get_address())
return (sig_hex + f"{v:02x}").lower()               # → "00" or "01"

Recommendation

Determine the canonical format expected by TronGrid (compare with compare_sign_consistency examples already in this PR). Once confirmed, apply the same convention in both adapters. If +27 is correct (consistent with TypeScript TronSigner), add the offset in Python:

v_byte = v + 27
return (sig_hex + f"{v_byte:02x}").lower()

[MJ-03] Python PrivyClient Blocks the Async Event Loop

Property Value
Severity Major
Category Performance
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 37–100

Description

PrivyClient uses urllib.request.urlopen, a synchronous blocking call. All PrivyAdapter methods (get_address, sign_transaction, sign_message, etc.) are declared async and call this client directly — without asyncio.run_in_executor or equivalent. Under asyncio (the assumed runtime for agent workloads), each Privy API call will stall the entire event loop until the HTTP round-trip completes. This blocks all other coroutines for the duration of the call, including timeouts and concurrency-dependent agent logic.

Code

# privy.py – adapter method
async def get_address(self) -> str:
    if self._cached_address:
        return self._cached_address
    payload = self._client.get_wallet(self._wallet_id)  # ← synchronous, blocks event loop
    ...

Recommendation

Either:

  1. (Preferred) Replace urllib.request with httpx (async) or aiohttp, making PrivyClient methods async def — consistent with the TypeScript client that uses fetch.
  2. (Interim) Wrap the synchronous calls in asyncio.get_event_loop().run_in_executor(None, ...) to offload to a thread pool, keeping the event loop unblocked.

[MJ-04] No HTTP Request Timeout in Python PrivyClient

Property Value
Severity Major
Category Correctness
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 63–70

Description

urlopen is called without a timeout parameter. If the Privy API is unreachable or slow, the call will block indefinitely. In production agent workflows this can cause the entire agent process to hang. The TypeScript client inherits a default timeout from the platform's fetch implementation (browser/Node 18+ defaults), but the Python client has no such protection.

Code

with urlopen(req) as response:   # ← no timeout
    status = getattr(response, "status", 0)
    payload = _read_json(response)

Recommendation

Pass a timeout argument (and make it configurable):

class PrivyClient:
    def __init__(self, *, app_id, app_secret, retries=2, timeout=30.0, sleep=None):
        ...
        self._timeout = timeout

# In _request:
with urlopen(req, timeout=self._timeout) as response:
    ...

Minor

[MN-01] normalizeTypedDataPayload Mutates the Caller's Input Object

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 218–227 / packages/python/src/agent_wallet/core/adapters/privy.py : Lines 199–208

Description

normalizeTypedDataPayload modifies the typed_data object in-place when renaming primaryTypeprimary_type. Because JavaScript objects and Python dicts are passed by reference, the caller's original data dict is silently mutated. This is surprising behavior and can cause hard-to-trace bugs if the caller reuses the same object.

Code

// privy.ts
function normalizeTypedDataPayload(data: Record<string, unknown>): Record<string, unknown> {
  const payload = 'typed_data' in data ? data : { typed_data: data }
  const typed = payload.typed_data as Record<string, unknown> | undefined
  if (!typed) return payload
  if ('primaryType' in typed && !('primary_type' in typed)) {
    typed.primary_type = typed.primaryType   // mutates caller's object
    delete typed.primaryType                 // mutates caller's object
  }
  return payload
}

Recommendation

Work on a shallow copy of typed_data:

function normalizeTypedDataPayload(data: Record<string, unknown>): Record<string, unknown> {
  const rawTyped = 'typed_data' in data ? data.typed_data : data
  const typed = { ...(rawTyped as Record<string, unknown>) }  // shallow copy
  if ('primaryType' in typed && !('primary_type' in typed)) {
    typed.primary_type = typed.primaryType
    delete typed.primaryType
  }
  return 'typed_data' in data ? { ...data, typed_data: typed } : { typed_data: typed }
}

[MN-02] bs58checkInterop Compatibility Shim Duplicated Between privy.ts and tron.ts

Property Value
Severity Minor
Category Code Quality
File packages/typescript/src/core/adapters/privy.ts : Lines 7–19 / packages/typescript/src/core/adapters/tron.ts : Lines 5–17

Description

The identical CJS/ESM interop shim for bs58check appears verbatim in both privy.ts and tron.ts. Code duplication means any future fix (e.g., when bs58check releases a cleaner export) must be applied in two places.

Code

// Duplicated in both privy.ts and tron.ts:
type Bs58checkLike = { encode?: (input: Uint8Array) => string; default?: typeof bs58checkModule }
const bs58checkInterop = bs58checkModule as Bs58checkLike
const bs58check: typeof bs58checkModule =
  typeof bs58checkInterop.encode === 'function'
    ? bs58checkModule
    : (bs58checkInterop.default ?? bs58checkModule)

Recommendation

Extract to packages/typescript/src/core/utils/bs58check.ts and import from both adapters.


[MN-03] EnvWalletResolved Is a Superfluous Single-Variant Union

Property Value
Severity Minor
Category Code Quality
File packages/typescript/src/core/providers/wallet-builder.ts : Lines 38–44

Description

EnvWalletResolved is declared as a discriminated union with only one variant. A plain object type would be simpler, less noisy, and equally expressive. The Python equivalent (EnvWalletResolved = tuple[..., str | None]) is a tuple type alias, which is also inconsistent with the TypeScript definition.

Code

export type EnvWalletResolved =
  | {
      params: RawSecretPrivateKeyParams | RawSecretMnemonicParams
      network: string | undefined
    }

Recommendation

export type EnvWalletResolved = {
  params: RawSecretPrivateKeyParams | RawSecretMnemonicParams
  network: string | undefined
}

[MN-04] Test-Mode Scrypt Downgrade via Env Var Poses Production Risk

Property Value
Severity Minor
Category Security
File packages/typescript/src/local/kv-store.ts : Lines 42–60 / packages/python/src/agent_wallet/local/kv_store.py : Lines 34–51

Description

getScryptParams() / _scrypt_params() reduce the Keystore V3 N parameter to 16384 (1/16 of the production value) whenever the VITEST or PYTEST_CURRENT_TEST env vars are set. The AGENT_WALLET_TEST_SCRYPT_N env var allows arbitrary further reduction. If any of these are set in a production environment (e.g., via a misconfigured CI deployment), newly encrypted keystores will use a far weaker KDF, lowering brute-force resistance by a factor of 16×.

Recommendation

Add a guard that explicitly refuses to apply test parameters if the process appears to be running in production (e.g., NODE_ENV === 'production'). Log a visible warning if test scrypt parameters are active. Alternatively, gate the entire mechanism behind a compile-time constant that is stripped from release builds.


[MN-05] resolveNetwork Changed from Throwing to Returning undefined — Silent Breakage Risk

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/utils/network.ts : Lines 11–17 / packages/python/src/agent_wallet/core/utils/network.py : Lines 19–24

Description

The old resolveNetwork raised an error when both explicit and providerDefault were undefined. The new version returns undefined silently. For Privy wallets this is correct (network-agnostic). For RawSecretSigner and LocalSigner, undefined is passed to parseNetworkFamily, which does throw — but the error surface has moved downstream, making it harder to trace. There is no early-fail guard for callers that expect a network to be set.

Recommendation

Consider an overloaded or typed variant — e.g., resolveRequiredNetwork(explicit, default): string (throws) and resolveOptionalNetwork(...): string | undefined (returns undefined) — so callers can express their intent explicitly and IDE type-checking catches mismatches.


Suggestions

[S-01] Add Jitter to Retry Backoff in Both Clients

File: packages/typescript/src/core/clients/privy.ts : Line 152 / packages/python/src/agent_wallet/core/clients/privy.py : Line 121

Description: Both clients use a simple linear backoff (200ms * attempt / 0.2s * attempt). With multiple concurrent agents hitting Privy simultaneously, all will retry at the same interval — a classic thundering-herd scenario.

Suggestion: Add randomized jitter: backoffMs = Math.min(1000, 200 * attempt) + Math.random() * 100.


[S-02] Export PrivyClient and PrivyConfigResolver Only as Internal — or Document Public API Contract

File: packages/typescript/src/index.ts : Lines 44–46

Description: PrivyClient and PrivyConfigResolver are now part of the public index.ts exports. These are low-level infrastructure classes; exporting them signals they are stable public API. If they change (e.g., async client rewrite), it constitutes a semver-breaking change.

Suggestion: Either mark them @internal in JSDoc, place them behind a sub-path export (@bankofai/agent-wallet/privy), or deliberately accept the API commitment. Similarly document in Python's __all__.


[S-03] PrivyConfigResolver Should Support Env Var Fallback

File: packages/typescript/src/core/providers/privy-config.ts / packages/python/src/agent_wallet/core/providers/privy_config.py

Description: All other credential sources (AGENT_WALLET_PASSWORD, AGENT_WALLET_PRIVATE_KEY, etc.) support env var injection. PrivyConfigResolver only reads from source object — there is no PRIVY_APP_ID / PRIVY_APP_SECRET / PRIVY_WALLET_ID fallback. This forces every deployment to write Privy credentials to a config file.

Suggestion: Extend PrivyConfigResolver to fall back to AGENT_WALLET_PRIVY_APP_ID, AGENT_WALLET_PRIVY_APP_SECRET, and AGENT_WALLET_PRIVY_WALLET_ID env vars, consistent with the env-first pattern already used by the raw-secret provider.


[S-04] Python PrivyAdapter._tron_sign_bytes Uses tronpy.hash_message While TypeScript Uses Raw keccak256 — Verify Equivalence

File: packages/python/src/agent_wallet/core/adapters/privy.py : Lines 130–135

Description: tronpy.keys.hash_message may apply a TRON-specific prefix before hashing (similar to Ethereum's \x19Ethereum Signed Message:\n prefix). If it does, the Python and TypeScript Privy adapters will sign different hashes for the same input bytes, breaking cross-implementation consistency.

Suggestion: Replace tronpy.keys.hash_message(data) with a direct keccak(data) call (using eth_utils.keccak) to match the TypeScript keccak256(bytes) behavior, and add a cross-language consistency test (the compare_sign_consistency examples already added are a good model).


Positive Observations

Area Observation
Dual-language parity Python and TypeScript implementations closely mirror each other's structure, making it easier to maintain both in lockstep
Utility extraction env.ts, hex.ts, keys.ts, network.ts (and Python equivalents) cleanly decouple shared logic from adapters, reducing import coupling
Error taxonomy Four distinct Privy error types (PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError) give callers precise error handling ability
Cache strategy upgrade Changing the config-provider wallet cache from a flat "id:network" string to a typed (id, type, network) tuple correctly handles Privy wallets that have no network dimension
SignOptions propagation The authorizationSignature option is threaded through every signing method consistently, ready for Privy server-authorization workflows
TRON txID flexibility Both TronSigner and PrivyAdapter now accept transactions without a pre-computed txID, computing SHA256(raw_data_hex) locally — a useful robustness improvement
Test coverage New files (test_privy_adapter, test_privy_client, test_privy_config, privy-adapter.test.ts, privy-client.test.ts, privy-config.test.ts) provide dedicated unit tests for all new components
Address resolution The resolveWalletAddresses helper correctly returns a single canonical address for Privy (network-agnostic) vs. dual EVM+TRON addresses for local wallets — clean polymorphism

Checklist Results

Category Items Checked Pass Fail N/A Notes
Correctness 8 5 3 0 urlopen error handling (C-01), v-byte mismatch (MJ-02), input mutation (MN-01)
Security 5 3 2 0 app_secret plaintext (MJ-01), test scrypt via env (MN-04)
Performance 4 2 2 0 Blocking I/O (MJ-03), no timeout (MJ-04)
Code Quality 6 4 2 0 bs58check duplication (MN-02), EnvWalletResolved type (MN-03)
Testing 5 5 0 0 Good coverage for all new components
Documentation 4 3 1 0 app_secret cleartext not documented; new env vars undocumented
Compatibility 3 3 0 0 Backward-compatible; new wallet type is additive
Observability 2 2 0 0 Error messages are descriptive

Disclaimer

This is an automated code review. It supplements but does not replace human review. The reviewer analyzed only the diff between main and feat/privy-support. Runtime behavior, integration testing against live Privy API, and deployment impact are not covered.


Report generated by Code Review Skill v1.0.0
Date: 2026-03-31

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 1, 2026

Code Review Report

Project: agent-wallet (bankofai-agent-wallet / @bankofai/agent-wallet)
PR: mainfeat/privy-support
Review Date: 2026-04-01
Reviewer: AI Code Reviewer (Code Review Skill v1.0.0)


PR Overview

Branch Information

Property Value
From Branch main
To Branch feat/privy-support
Commits 6
Files Changed 116
Lines Added +9,693
Lines Removed -921

Commit History

Hash Message
a7c9aac chore: update v2.4.0
9bfe661 feat: change-password no interact mode
502b4ed feat: resolve-address comamnd
c287ebf feat: resolve address
5f58664 feat: optimize hierachical command and optimize remove command ux
c4fba9a feat: privy support

Review Summary

Verdict

Verdict: Request Changes — Critical and major defects must be resolved before this PR is safe to merge.

Findings at a Glance

Critical Major Minor Suggestion
Count 2 4 5 3

Summary

This PR introduces Privy as a third wallet provider type alongside the existing local_secure and raw_secret types. Privy is an API-backed, MPC/cloud-custodied wallet service, and the integration is architecturally sound: a new PrivyAdapter (TypeScript and Python) implements the Wallet interface, a new PrivyClient wraps the Privy HTTP API, and PrivyConfigResolver validates configuration. The diff also carries several valuable independent improvements: SignOptions (authorization-signature threading), txID auto-derivation via SHA-256 for TRON, address-resolution commands, refactored utility modules, and reduced scrypt parameters in test environments for faster CI.

However, the implementation has two critical defects that could cause production failures. First, the Python PrivyClient uses urllib.request.urlopen, which raises an exception for every HTTP 4xx/5xx response; this makes the rate-limit retry loop and the typed error classification (PrivyAuthError, PrivyRateLimitError) completely unreachable. Second, the Privy app_secret is persisted in plaintext inside wallets_config.json — an unencrypted file — defeating the encrypted-keystore security model the rest of the SDK is built upon.

Four additional major findings round out the review: blocking synchronous I/O inside async Python methods, missing HTTP timeout on urlopen, an empty-string cache-miss that causes repeated getWallet API calls on every signing operation for EVM wallets, and kdfparams read from disk without bounds validation allowing trivial brute-force reduction if the keystore is tampered with.


Change Summary

1 — New Privy Wallet Provider (Core)

File Change Type Description
packages/typescript/src/core/adapters/privy.ts Added PrivyAdapter implementing Wallet + Eip712Capable
packages/python/src/agent_wallet/core/adapters/privy.py Added Python mirror of PrivyAdapter
packages/typescript/src/core/clients/privy.ts Added PrivyClient HTTP wrapper for Privy API
packages/python/src/agent_wallet/core/clients/privy.py Added Python PrivyClient using urllib
packages/typescript/src/core/providers/privy-config.ts Added PrivyConfigResolver – validates app_id, app_secret, wallet_id
packages/python/src/agent_wallet/core/providers/privy_config.py Added Python mirror of PrivyConfigResolver

Purpose: Adds Privy as a cloud-custody signing backend. The adapter routes EVM signing through Privy's personal_sign / eth_signTypedData_v4 / eth_signTransaction RPC methods and handles TRON via raw_sign + local recovery-id computation.


2 — Config & Provider Extension

File Change Type Description
packages/typescript/src/core/config.ts Modified Adds PrivyWalletParams schema; extends WalletConfigSchema to accept 'privy' type
packages/python/src/agent_wallet/core/config.py Modified Python mirror: PrivyWalletParams Pydantic model, validator for privy type
packages/typescript/src/core/providers/config-provider.ts Modified Handles privy type in getWallet (skips network resolution)
packages/typescript/src/core/providers/wallet-builder.ts Modified Factory creates PrivyAdapter for WalletType.PRIVY
packages/python/src/agent_wallet/core/providers/wallet_builder.py Modified Python mirror

Purpose: Wire the new privy wallet type through the existing config-driven provider infrastructure.


3 — SignOptions Threading

File Change Type Description
packages/typescript/src/core/base.ts Modified Adds SignOptions type (authorizationSignature?: string); extends Wallet interface
packages/python/src/agent_wallet/core/base.py Modified Python mirror: SignOptions dataclass
packages/typescript/src/core/adapters/evm.ts Modified Accepts options?: SignOptions in all signing methods (unused for local signing)
packages/typescript/src/core/adapters/tron.ts Modified Same
packages/typescript/src/core/adapters/local.ts Modified Propagates options to underlying signer
packages/python/src/agent_wallet/core/adapters/{evm,tron,local}.py Modified Python mirrors

Purpose: Allows callers to pass a Privy-required privy-authorization-signature header through the generic Wallet interface without breaking existing local signers.


4 — TRON txID Auto-Derivation

File Change Type Description
packages/typescript/src/core/adapters/tron.ts Modified signTransaction no longer requires txID; derives it as SHA256(raw_data_hex) when missing
packages/python/src/agent_wallet/core/adapters/tron.py Modified Python mirror

Purpose: Makes the SDK resilient to callers that omit txID from unsigned TRON transactions.


5 — Address Resolution Command

File Change Type Description
packages/typescript/src/core/address-resolution.ts Added Resolves both EVM and TRON addresses for a given wallet config entry
packages/python/src/agent_wallet/core/address_resolution.py Added Python mirror
packages/typescript/src/delivery/cli.ts Modified New resolve-address / resolve address command
packages/python/src/agent_wallet/delivery/cli.py Modified New resolve-address command; extensive UX improvements

Purpose: CLI command to display a wallet's addresses across both network families.


6 — KV-Store / Keystore V3 Improvements

File Change Type Description
packages/typescript/src/local/kv-store.ts Modified scrypt params read from keystore on decrypt; reduced N for Vitest/PYTEST environments
packages/python/src/agent_wallet/local/kv_store.py Modified Same; PYTEST_CURRENT_TEST env var triggers reduced N

Purpose: Fixes a latent bug where decryption always used hardcoded params regardless of what params were stored in the keystore. Also speeds up tests significantly.


7 — Utility Module Refactor

File Change Type Description
packages/typescript/src/core/utils/{env,hex,keys,network}.ts Added Extracted from local.ts, raw-secret.ts, wallet-builder.ts
packages/python/src/agent_wallet/core/utils/{env,hex,keys,network}.py Added Python mirrors

Purpose: DRY refactor — shared parsing/derivation utilities now live in their own modules.


Detailed Findings


Critical

[C-01] Python PrivyClient._request()urlopen raises on all HTTP errors; rate-limit retry and typed error handling are unreachable

Property Value
Severity Critical
Category Correctness
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 56–84

Description

urllib.request.urlopen raises urllib.error.HTTPError (a subclass of urllib.error.URLError) for any response with a 4xx or 5xx status code — it does not return the response object. The current code reads status and payload after the with urlopen(req) block, which means urlopen throws before those lines can execute. As a result:

  • The rate-limit retry loop (if status == 429) is never reached; a 429 from Privy surfaces as an unhandled HTTPError to the caller.
  • The typed error classification (PrivyAuthError for 401/403, PrivyRequestError for other 4xx) is never executed.
  • All 4xx/5xx errors propagate as raw urllib.error.HTTPError objects, bypassing the SDK's error hierarchy entirely.

The TypeScript counterpart using fetch is correct because fetch does not throw on non-2xx responses.

Code

# packages/python/src/agent_wallet/core/clients/privy.py
def _request(self, method, path, body=None, *, authorization_signature=None):
    attempt = 0
    while True:
        req = Request(...)
        with urlopen(req) as response:       # ← raises HTTPError for 4xx/5xx
            status = getattr(response, "status", 0)   # never reached on error
            payload = _read_json(response)             # never reached on error

        if status == 429:        # dead code for any 429 response
            if attempt >= self._retries:
                raise PrivyRateLimitError(...)
            attempt += 1
            self._sleep(_backoff_seconds(attempt))
            continue

        if status >= 400:        # dead code for any 4xx/5xx response
            ...
            raise PrivyAuthError(message)
            raise PrivyRequestError(message)

Recommendation

Catch urllib.error.HTTPError, which itself exposes .code, .read():

import urllib.error

def _request(self, method, path, body=None, *, authorization_signature=None):
    attempt = 0
    while True:
        req = Request(
            f"{self._base_url}{path}",
            method=method,
            headers=self._headers(authorization_signature),
            data=json.dumps(body).encode("utf-8") if body else None,
        )
        try:
            with urlopen(req, timeout=30) as response:
                payload = _read_json(response)
            return payload
        except urllib.error.HTTPError as exc:
            status = exc.code
            try:
                payload = json.loads(exc.read().decode("utf-8"))
            except Exception:
                payload = {}
            if status == 429:
                if attempt >= self._retries:
                    raise PrivyRateLimitError("Privy rate limit exceeded") from exc
                attempt += 1
                self._sleep(_backoff_seconds(attempt))
                continue
            message = _extract_error(payload) or f"Privy request failed with status {status}"
            if status in (401, 403):
                raise PrivyAuthError(message) from exc
            raise PrivyRequestError(message) from exc
        except urllib.error.URLError as exc:
            raise PrivyRequestError(f"Privy network error: {exc.reason}") from exc

[C-02] app_secret persisted in plaintext in wallets_config.json

Property Value
Severity Critical
Category Security
File packages/typescript/src/core/config.ts : Lines 43–48; packages/python/src/agent_wallet/core/config.py : Lines 41–49

Description

PrivyWalletParams is serialised directly into wallets_config.json as plain JSON. This means the Privy app_secret — which grants full signing authority over any wallet associated with the app — is stored unencrypted on disk. The existing local_secure wallet type encrypts private keys using Keystore V3 (scrypt + AES-128-CTR) precisely to prevent credential exposure from a stolen config file. Introducing a new wallet type that skips this protection is a regression in the security model.

Code

// packages/typescript/src/core/config.ts
export const PrivyWalletParamsSchema = z.object({
  app_id: z.string(),
  app_secret: z.string(),   // ← written verbatim to wallets_config.json
  wallet_id: z.string(),
})
// resulting wallets_config.json entry (example)
{
  "type": "privy",
  "params": {
    "app_id": "clxxxxxxxxxxxxxxx",
    "app_secret": "actual-secret-in-plaintext",
    "wallet_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  }
}

Recommendation

Apply one or more of the following mitigations:

  1. Env-var resolution (preferred short-term): Resolve app_secret from an environment variable (e.g. PRIVY_APP_SECRET) at runtime and never persist it to disk. Store only app_id and wallet_id in the config file.

  2. Keystore encryption (preferred long-term): Encrypt app_secret with the master password using the same Keystore V3 mechanism used for private keys, storing the keystore blob as secret_<id>.json and referencing it by secret_ref in the config.

  3. Minimum: Document clearly that wallets_config.json contains sensitive credentials and ensure the file permissions (already 0o700 on the directory) are enforced on the file itself.


Major

[MJ-01] Python PrivyAdapter performs synchronous (blocking) HTTP I/O inside async methods

Property Value
Severity Major
Category Performance / Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 31–34, 50–57, 63–70, 75–81

Description

PrivyAdapter.get_address(), sign_transaction(), sign_message(), sign_typed_data(), _tron_sign_hash(), etc. are all declared async def but call self._client.get_wallet(), self._client.rpc(), and self._client.raw_sign() which are synchronous methods backed by urllib.request.urlopen (blocking I/O). Running blocking I/O directly inside an asyncio event loop blocks the thread for the duration of each HTTP request, preventing the event loop from processing any other coroutines.

# packages/python/src/agent_wallet/core/adapters/privy.py
async def get_address(self) -> str:
    ...
    payload = self._client.get_wallet(self._wallet_id)   # ← blocks the event loop
    ...

async def _tron_sign_hash(self, digest: bytes, options=None) -> str:
    response = self._client.raw_sign(...)                # ← blocks the event loop

Recommendation

Use asyncio.get_event_loop().run_in_executor(None, ...) to run the synchronous calls in a thread pool, or replace urllib with an async HTTP client (aiohttp, httpx[asyncio]):

import asyncio

async def _async_request(self, *args, **kwargs):
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(
        None, lambda: self._client._request(*args, **kwargs)
    )

Alternatively, expose an async PrivyClient backed by aiohttp/httpx and inject it.


[MJ-02] Missing HTTP request timeout in Python PrivyClient

Property Value
Severity Major
Category Correctness / Performance
File packages/python/src/agent_wallet/core/clients/privy.py : Line 65

Description

urlopen(req) is called without a timeout argument. Python's default is no timeout, meaning a request can hang indefinitely if the Privy API server is unreachable or unresponsive. This would deadlock any async context and hang CLI commands.

The TypeScript fetch also does not set a timeout, but browsers and Node.js fetch implementations typically surface errors more quickly; the Python case is more hazardous given the synchronous-in-async issue noted in MJ-01.

Code

# packages/python/src/agent_wallet/core/clients/privy.py  line ~65
with urlopen(req) as response:   # no timeout – can hang indefinitely

Recommendation

PRIVY_REQUEST_TIMEOUT_SECONDS = 30

with urlopen(req, timeout=PRIVY_REQUEST_TIMEOUT_SECONDS) as response:
    ...

Consider making the timeout configurable via PrivyClientConfig.


[MJ-03] Empty chainType string causes cache miss — getWallet API called on every signing operation

Property Value
Severity Major
Category Correctness / Performance
File packages/typescript/src/core/adapters/privy.ts : Lines 26, 95–100; packages/python/src/agent_wallet/core/adapters/privy.py : Lines 38–43

Description

Both PrivyAdapter.getAddress() and getChainType() use the truthiness of the cached value to decide whether to skip the API call:

// TypeScript
if (this.cachedAddress) return this.cachedAddress      // falsy on ""
if (this.cachedChainType) return this.cachedChainType  // falsy on ""
# Python
if self._cached_chain_type:
    return self._cached_chain_type                     # falsy on ""

For an EVM wallet, the Privy GET /v1/wallets/{id} response returns chain_type = "ethereum" (or similar), which is truthy. However, if the API ever returns an empty string, null, or an unknown variant, the adapter stores "" (the normalised fallback) and re-fetches on every subsequent call. Since signTransaction, signMessage, and signTypedData all call getChainType(), a single signing session could generate O(n) redundant wallet-lookup API calls. Because getAddress() has the same guard, the first address fetch would also not be cached.

Code

// packages/typescript/src/core/adapters/privy.ts
private async getChainType(): Promise<string> {
  if (this.cachedChainType) return this.cachedChainType  // '' is falsy
  const wallet = await this.client.getWallet(this.config.walletId)
  this.cachedAddress = wallet.address
  this.cachedChainType = wallet.chainType?.toLowerCase() ?? ''
  return this.cachedChainType
}

Recommendation

Use null as the sentinel (not '') to distinguish "not yet fetched" from "API returned empty":

private cachedChainType: string | null = null

private async getChainType(): Promise<string> {
  if (this.cachedChainType !== null) return this.cachedChainType
  const wallet = await this.client.getWallet(this.config.walletId)
  this.cachedAddress = wallet.address
  this.cachedChainType = wallet.chainType?.toLowerCase() ?? ''
  return this.cachedChainType
}

Apply the same fix to getAddress() and the Python adapter.


[MJ-04] kdfparams values from keystore file are trusted without bounds validation

Property Value
Severity Major
Category Security
File packages/typescript/src/local/kv-store.ts : Lines 113–119; packages/python/src/agent_wallet/local/kv_store.py : Lines 116–123

Description

Prior to this PR, scrypt parameters were hardcoded constants. The new decryptBytes reads n, r, p, dklen from the keystore JSON file and passes them directly to the scrypt KDF. If an attacker can modify the keystore file (e.g. via path traversal, compromised backup, or a shared filesystem), they can set n=2 to reduce the scrypt work factor to nearly zero, making an offline dictionary attack trivially fast.

This is a known Keystore V3 design tradeoff, but the risk should be mitigated by at minimum validating that N is not below a safe floor.

Code

// packages/typescript/src/local/kv-store.ts
const derivedKey = deriveKey(password, salt, {
  N: Number(kdfparams.n),      // no lower-bound check
  r: Number(kdfparams.r),
  p: Number(kdfparams.p),
  dklen: Number(kdfparams.dklen),
})

Recommendation

Validate parameters before use:

const MIN_SCRYPT_N = 16384  // same as test value; absolute floor

function validateScryptParams(params: { N: number; r: number; p: number; dklen: number }) {
  if (!Number.isInteger(params.N) || params.N < MIN_SCRYPT_N)
    throw new DecryptionError(`Invalid scrypt N: ${params.N} (minimum ${MIN_SCRYPT_N})`)
  if (!Number.isInteger(params.r) || params.r < 1)
    throw new DecryptionError(`Invalid scrypt r: ${params.r}`)
  if (!Number.isInteger(params.p) || params.p < 1)
    throw new DecryptionError(`Invalid scrypt p: ${params.p}`)
  if (!Number.isInteger(params.dklen) || params.dklen < 16)
    throw new DecryptionError(`Invalid scrypt dklen: ${params.dklen}`)
}

Minor

[MN-01] recoverTronRecoveryId throws UnsupportedOperationError for a signing failure

Property Value
Severity Minor
Category Code Quality
File packages/typescript/src/core/adapters/privy.ts : Lines 217–228; packages/python/src/agent_wallet/core/adapters/privy.py : Lines 228–236

Description

When neither recovery bit (0 or 1) produces a public key that matches the wallet address, the function throws UnsupportedOperationError. This is semantically incorrect — the operation (TRON signing) is supported; what failed is the post-signing recovery computation, which is a signing error.

Recommendation

throw new SigningError('Unable to derive recovery id for TRON signature — address mismatch')

[MN-02] TypeScript PrivyClient.rpc() body has inconsistent indentation

Property Value
Severity Minor
Category Code Quality
File packages/typescript/src/core/clients/privy.ts : Lines 60–67

Description

The rpc method has misaligned braces in the request body object, inconsistent with the rawSign method immediately below it.

Code

return this.request(
  'POST',
  `/v1/wallets/${walletId}/rpc`,
  {
  method,    // ← not indented relative to the opening brace
  params,
  },
  options,
)

Recommendation

return this.request(
  'POST',
  `/v1/wallets/${walletId}/rpc`,
  {
    method,
    params,
  },
  options,
)

[MN-03] No environment-variable support for Privy credentials

Property Value
Severity Minor
Category Documentation / Usability
File packages/typescript/src/core/providers/privy-config.ts (all); Python mirror

Description

PrivyConfigResolver reads only from the source object passed to its constructor. Unlike other credential types that fall back to environment variables (AGENT_WALLET_PRIVATE_KEY, AGENT_WALLET_MNEMONIC, etc.), there is no support for env vars like PRIVY_APP_SECRET. This forces all users to write the app_secret into the config file, which is the root cause of finding C-02.

Recommendation

Extend PrivyConfigResolver._merge() to overlay env vars:

private merge(): PrivyConfigSource {
  return {
    app_id:     process.env.PRIVY_APP_ID     ?? normalizeValue(this.source?.app_id),
    app_secret: process.env.PRIVY_APP_SECRET ?? normalizeValue(this.source?.app_secret),
    wallet_id:  process.env.PRIVY_WALLET_ID  ?? normalizeValue(this.source?.wallet_id),
  }
}

Document the new env vars in CLAUDE.md / README.md.


[MN-04] AGENT_WALLET_TEST_SCRYPT_N and PYTEST_CURRENT_TEST reduce cryptographic strength at runtime

Property Value
Severity Minor
Category Security
File packages/typescript/src/local/kv-store.ts : Lines 38–55; packages/python/src/agent_wallet/local/kv_store.py : Lines 38–60

Description

The KV-store now checks process.env.VITEST (TS) or os.environ.get("PYTEST_CURRENT_TEST") (Python) at runtime and reduces scrypt N from 262 144 to 16 384 whenever those variables are present. While PYTEST_CURRENT_TEST is only set by pytest, AGENT_WALLET_TEST_SCRYPT_N is an arbitrary env var that any operator could accidentally set in a staging or production environment, silently weakening encryption of any wallets created during that session.

Recommendation

Consider moving this logic to build-time or test-fixture injection rather than runtime env-var inspection. Alternatively, print a warning to stderr whenever the reduced parameter path is taken:

import warnings
if os.environ.get("PYTEST_CURRENT_TEST"):
    warnings.warn("Using reduced scrypt N (test mode) — do not use in production", stacklevel=2)

[MN-05] normalizeTronTxPayload txID validation regex accepts uppercase hex but docs say lowercase

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 192–208

Description

normalizeTronTxPayload validates txID with /^[0-9a-fA-F]{64}$/ (case-insensitive hex) but does not normalise to lowercase before returning. The TRON signing path in PrivyAdapter.tronSignTransaction then embeds this mixed-case txID directly into the returned JSON. TRON node APIs may be case-sensitive.

Recommendation

Normalise to lowercase on acceptance:

const txId = stripHexPrefix(txIdRaw.trim()).toLowerCase()

The Python _normalize_tron_payload already calls .hexdigest() (inherently lowercase) for the computed path but silently passes through the caller-supplied txID unchanged.


Suggestions

[S-01] Consider adding a walletId format guard before making API calls

File: packages/typescript/src/core/clients/privy.ts, packages/python/src/agent_wallet/core/clients/privy.py
Description: The wallet ID is passed directly into the URL path (/v1/wallets/${walletId}/rpc). A malformed wallet ID (e.g., containing / or ..) could result in hitting unintended API endpoints. A lightweight UUID-format check on walletId before use would mitigate this risk.
Suggestion: Validate the wallet ID matches /^[0-9a-f-]{36}$/i (UUID pattern) in PrivyConfigResolver.resolve().


[S-02] PrivyAdapter address/chain-type fetching could be consolidated into a single ensureLoaded() call

File: packages/typescript/src/core/adapters/privy.ts : Lines 36–42, 95–100
Description: Both getAddress() and getChainType() independently call this.client.getWallet(...) if their respective cache is cold, potentially causing two sequential API round trips during first use if both are needed (e.g., signTransaction calls getChainType, which is cold; then tronSignTransaction could call getAddress, which is also cold).
Suggestion: Introduce a private ensureLoaded() that fetches once and populates both cachedAddress and cachedChainType:

private async ensureLoaded(): Promise<void> {
  if (this.cachedAddress !== null) return
  const wallet = await this.client.getWallet(this.config.walletId)
  this.cachedAddress = wallet.address
  this.cachedChainType = wallet.chainType?.toLowerCase() ?? ''
}

[S-03] Python PrivyAdapter._tron_sign_bytes uses tronpy.keys.hash_message while TypeScript uses keccak256

File: packages/python/src/agent_wallet/core/adapters/privy.py : Lines 109–113; packages/typescript/src/core/adapters/privy.ts : Lines 118–121
Description: The TypeScript adapter computes keccak256(bytes) for tronSignBytes; the Python adapter calls tronpy.keys.hash_message(data), which prepends a TRON-specific message prefix before hashing. These two implementations will produce different digests for the same input, breaking cross-language consistency tests.
Suggestion: Align both implementations. Verify which behaviour is expected by Privy for raw_sign and document it explicitly.


Positive Observations

Area Observation
Interface consistency SignOptions is threaded through every signing method in both TypeScript and Python, allowing callers to attach an authorizationSignature without any breaking change to existing code.
Dual-language parity Nearly every TypeScript change has a Python mirror. The structural symmetry is maintained well.
TRON recovery-id computation The local recovery-id brute-force (try bit 0, then 1, compare recovered address) is a correct and clean approach for a service that returns only r||s without v.
Keystore V3 fix Reading actual kdfparams from the keystore on decryption (instead of hardcoded constants) fixes a real latent bug where cross-environment keystores with different params would silently fail.
txID resilience Allowing signTransaction to derive txID = SHA256(raw_data_hex) when missing makes the SDK more robust to partially-formed unsigned transactions from TronGrid.
PrivyConfigResolver.isEnabled() Clean, side-effect-free check for whether Privy config is complete, preventing noisy errors during provider resolution when Privy is not configured.
Error type hierarchy Four Privy-specific error classes (PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError) are well-scoped and exported from the public API surface.
Retry with back-off The TypeScript PrivyClient correctly implements exponential back-off with a configurable sleep injectable for testing.
Test coverage New test files (privy-adapter.test.ts, privy-client.test.ts, privy-config.test.ts) provide meaningful unit coverage of the new modules; the adapter tests use vi.fn() mocks properly.

Checklist Results

Category Items Checked Pass Fail N/A Notes
Correctness 8 5 3 0 C-01 (urlopen), MJ-03 (cache miss), MN-05 (txID case)
Security 6 3 2 1 C-02 (plaintext secret), MJ-04 (kdfparams trust)
Performance 5 3 2 0 MJ-01 (blocking async), MJ-02 (no timeout)
Code Quality 7 5 1 1 MN-01 (wrong error type), MN-02 (indentation)
Testing 5 4 0 1 Good coverage of new modules
Documentation 4 2 1 1 MN-03 (missing env var docs)
Compatibility 3 3 0 0 SignOptions is additive; no breaking changes
Observability 3 2 0 1 No sensitive data in logs

Disclaimer

This is an automated code review. It supplements but does not replace human review. The reviewer analysed only the diff between main and feat/privy-support. Runtime behaviour (e.g. actual Privy API responses), integration testing, and deployment impact are not covered.


Report generated by Code Review Skill v1.0.0
Date: 2026-04-01

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 1, 2026

Audit Report — PR: feat/privy-support

Date: 2026-04-01
Reviewer: Claude Sonnet 4.6 (automated code review)
Repository: agent-wallet
Base Branch: remotes/origin/main
Feature Branch: remotes/origin/feat/privy-support


1. PR Overview

Branch Info

Property Value
Base remotes/origin/main
Feature remotes/origin/feat/privy-support
Commits 7

Commit Summary

8ad9238 fix: client error parse
a7c9aac chore: update v2.4.0
9bfe661 feat: change-password no interact mode
502b4ed feat: resolve-address command
c287ebf feat: resolve address
5f58664 feat: optimize hierarchical command and optimize remove command ux
c4fba9a feat: privy support

Change Statistics

  • 116 files changed
  • 9,703 insertions, 921 deletions

Key Files Changed

Category Files
New TypeScript core core/adapters/privy.ts, core/clients/privy.ts, core/providers/privy-config.ts, core/address-resolution.ts
New Python core core/adapters/privy.py, core/clients/privy.py, core/providers/privy_config.py, core/address_resolution.py
Modified (TS) core/base.ts, core/config.ts, core/errors.ts, core/providers/config-provider.ts, core/providers/wallet-builder.ts, core/resolver.ts, delivery/cli.ts, local/kv-store.ts
Modified (Python) core/base.py, core/config.py, core/errors.py, core/providers/config_provider.py, core/providers/wallet_builder.py, core/resolver.py, delivery/cli.py, local/kv_store.py
New utilities `core/utils/env.ts
New tests tests/privy-adapter.test.ts, tests/privy-client.test.ts, tests/privy-config.test.ts (TS); tests/test_privy_adapter.py, tests/test_privy_client.py, tests/test_privy_config.py (Py)
Docs doc/how-to-add-privy-wallet.md, AGENTS.md, README.md update

2. Change Summary

2.1 Privy Adapter Layer (Core Feature)

A new PrivyAdapter class (both TypeScript and Python) implements the Wallet and Eip712Capable interfaces via Privy's API rather than a local private key. The adapter:

  • Calls GET /v1/wallets/{walletId} to discover chain type (EVM vs TRON) and caches the result.
  • Routes signMessagepersonal_sign, signTransactioneth_signTransaction, signTypedDataeth_signTypedData_v4 via the Privy RPC endpoint.
  • For TRON wallets, calls raw_sign and manually recovers the v byte by trying recovery ∈ {0,1} and matching the address.

2.2 Privy HTTP Client

PrivyClient is a thin HTTP wrapper over the Privy API, using:

  • Basic Auth with appId:appSecret as credentials.
  • Exponential backoff retry for HTTP 429 (rate limit), up to retries attempts.
  • Custom sleep injection for testability (TypeScript: injectable; Python: injectable).
  • Error classes PrivyRequestError, PrivyRateLimitError, PrivyAuthError mapped from HTTP status codes.

2.3 Configuration Layer

PrivyWalletParams (Pydantic/Zod) stores app_id, app_secret, and wallet_id in wallets_config.json. The PrivyConfigResolver validates and normalizes these values, with no environment variable fallback in this PR.

2.4 Provider Integration

ConfigWalletProvider.getWallet() now branches on conf.type === 'privy' to skip network resolution (Privy wallets are network-agnostic), then delegates to createAdapter() which constructs PrivyAdapter.

2.5 Address Resolution

A new resolveWalletAddresses() function handles the CLI resolve-address command. For Privy wallets it fetches the address from the Privy API. For local wallets it derives EVM and TRON addresses from the private key.

2.6 CLI Additions

The start, add, and related commands were extended with Privy-specific credential prompting (app_id, app_secret, wallet_id). The resolve-address command is new. The change-password command gained a --no-interact mode.

2.7 Utility Refactor

Utilities previously inline or duplicated were extracted into core/utils/ sub-modules (env, hex, keys, network).


3. Detailed Findings


[severity-01] Privy app_secret stored in plaintext in wallets_config.json

Property Value
Severity Critical
Category Security
File packages/python/src/agent_wallet/core/config.py : Lines 42-48; packages/typescript/src/core/config.ts : Lines 44-48

Description:

PrivyWalletParams is stored as-is in wallets_config.json, meaning the app_secret is written as plaintext JSON on disk. Unlike local_secure wallets where private keys are AES-128-CTR encrypted inside Keystore V3 files (the whole point of SecureKVStore), a Privy app_secret is never encrypted — it sits at the same privilege level as a raw private key in a raw_secret wallet. However, the UX implies this is a "secure" alternative when it is not: an attacker with read access to ~/.agent-wallet/wallets_config.json can immediately make Privy API calls on behalf of all wallets configured for that app_id.

The app_secret is also printed verbatim in error messages through PrivyConfigError (the missing-keys error lists the key names but the resolved config object with the secret could leak through logging).

Code:

class PrivyWalletParams(BaseModel):
    """Privy wallet params for API-backed signing."""
    app_id: str
    app_secret: str      # <-- stored plaintext in wallets_config.json
    wallet_id: str

And save_config writes this through config.model_dump(exclude_none=True) with no secret scrubbing.

Recommendation:

Consider one of:

  1. Encrypt app_secret the same way local secrets are encrypted — store it in a secret_<ref>.json Keystore V3 file, store only a secret_ref in wallets_config.json, and decrypt at runtime using the master password. This is the established pattern in the codebase.
  2. Alternatively, support app_secret via an environment variable (e.g., AGENT_WALLET_PRIVY_APP_SECRET) so it is never persisted to disk.

At minimum, add a prominent warning in the documentation and CLI output when storing a Privy wallet that the app_secret will be in plaintext.


[severity-02] Python PrivyClient uses synchronous urlopen in async context without a thread executor

Property Value
Severity Major
Category Correctness
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 57-99

Description:

PrivyClient._request() is a synchronous method using urllib.request.urlopen (a blocking I/O call). It is called from multiple async methods in PrivyAdapter — e.g., get_address, sign_transaction, sign_message — which run in an async event loop. Calling blocking I/O directly from an async function blocks the entire event loop thread for the duration of the HTTP call. Under any real-world async usage this will cause significant performance degradation and can cause timeouts for other coroutines.

The TypeScript equivalent uses fetch (non-blocking), making this a structural divergence. The Python adapter methods are marked async (async def get_address, async def sign_message, etc.) but delegate to the synchronous client without await asyncio.to_thread(...) or equivalent.

Code:

# PrivyAdapter.get_address() — async method calls synchronous HTTP
async def get_address(self) -> str:
    if self._cached_address:
        return self._cached_address
    payload = self._client.get_wallet(self._wallet_id)   # BLOCKS the event loop
    ...

Recommendation:

Wrap the blocking HTTP calls in asyncio.to_thread:

import asyncio

async def get_address(self) -> str:
    if self._cached_address:
        return self._cached_address
    payload = await asyncio.to_thread(self._client.get_wallet, self._wallet_id)
    ...

Or convert PrivyClient to use aiohttp / httpx for async HTTP, keeping parity with the TypeScript fetch-based implementation.


[severity-03] tronSignBytes in TypeScript hashes with keccak256 but Python uses tronpy.keys.hash_message

Property Value
Severity Major
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 114-118; packages/python/src/agent_wallet/core/adapters/privy.py : Lines 116-122

Description:

In PrivyAdapter the TRON message signing path diverges between languages:

  • TypeScript (tronSignBytes): applies raw keccak256 to the input bytes.
  • Python (_tron_sign_bytes): calls tronpy.keys.hash_message(data), which applies the "TRON personal sign" prefix (\x19TRON Signed Message:\n + length) before hashing.

These two are not equivalent. A message signed via the TypeScript adapter will produce a different signature hash than the same message signed via the Python adapter, breaking interoperability. The compare_sign_consistency.py and compare-sign-consistency.ts example files exist precisely to detect this, but no automated test currently enforces cross-language consistency for TRON signMessage.

Code:

// TypeScript — privy.ts L114-118
private async tronSignBytes(bytes: Uint8Array, options?: SignOptions): Promise<string> {
    const hashHex = keccak256(bytes)       // Raw keccak256, no prefix
    const hash = Buffer.from(hashHex.slice(2), 'hex')
    return this.tronSignHash(hash, options)
}
# Python — privy.py L116-122
async def _tron_sign_bytes(self, data: bytes, options: SignOptions | None = None) -> str:
    from tronpy.keys import hash_message
    digest = hash_message(data)            # Applies TRON personal-sign prefix then hashes
    return await self._tron_sign_hash(digest, options)

Note: the TronSigner (local) also uses tronpy.keys.sign_msg in Python (which prefixes) vs keccak256 directly in TypeScript — but that inconsistency predates this PR. For the PrivyAdapter, a decision must be made and enforced consistently.

Recommendation:

Align both implementations to the same hashing strategy. Because Privy's raw_sign takes a raw hash (no additional prefix) and Tron's "personal_sign" behavior adds a prefix, decide which semantic signMessage should expose:

  • If signMessage should match the local TronSigner behavior, use the prefixed hash in both languages.
  • If signMessage should sign a raw digest, use raw keccak256 in both.

Add a cross-language consistency test (similar to the example scripts) that runs in CI.


[severity-04] TRON recovery ID loop silently swallows all exceptions

Property Value
Severity Major
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 289-307

Description:

recoverTronRecoveryId iterates recovery ∈ {0, 1}, wraps each attempt in a broad try/catch, and silently continues on any exception. If the signature has a valid recovery=0 candidate but the public key computation fails for an unexpected reason (e.g., invalid curve point, encoding error), the function falls through to recovery=1 which might also fail — and then throws an UnsupportedOperationError. The real root cause is masked completely. This pattern can hide bugs in the cryptographic path that would otherwise be immediately apparent.

Code:

for (const recovery of [0, 1]) {
    try {
        const sig = secp256k1.Signature.fromCompact(signature).addRecoveryBit(recovery)
        const pub = sig.recoverPublicKey(hash)
        const tronAddress = tronAddressFromPublicKey(pub.toRawBytes(false))
        if (tronAddress === address) {
            return recovery
        }
    } catch {
        continue   // Silently swallows all errors
    }
}
throw new UnsupportedOperationError('Unable to derive recovery id for TRON signature')

Recommendation:

Distinguish between expected secp256k1 errors (invalid recovery bit, point at infinity) and unexpected errors (invalid input shape, encoding failures). Re-throw unexpected errors immediately:

for (const recovery of [0, 1]) {
    try {
        const sig = secp256k1.Signature.fromCompact(signature).addRecoveryBit(recovery)
        const pub = sig.recoverPublicKey(hash)
        const tronAddress = tronAddressFromPublicKey(pub.toRawBytes(false))
        if (tronAddress === address) return recovery
    } catch (e) {
        if (e instanceof Error && e.message.includes('recovery')) continue
        throw e   // Unexpected errors surface immediately
    }
}

[severity-05] No timeout on Privy HTTP requests; unbounded blocking possible

Property Value
Severity Major
Category Performance / Correctness
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 57-99; packages/typescript/src/core/clients/privy.ts : Lines 88-123

Description:

Neither the Python (urlopen) nor the TypeScript (fetch) implementation sets an explicit timeout on individual HTTP requests to the Privy API. If the Privy API is slow or unresponsive, wallet operations block indefinitely. In an agent/automated context this could stall a pipeline forever. The retries parameter handles rate-limit retries but there is no circuit-breaker for hanging connections.

Code (Python):

with urlopen(req) as response:    # No timeout parameter
    ...

Code (TypeScript):

const response = await fetch(`${this.baseUrl}${path}`, {
    method,
    headers: this.buildHeaders(options),
    body: body ? JSON.stringify(body) : undefined,
    // No signal/AbortController
})

Recommendation:

Add a configurable timeout:

  • Python: urlopen(req, timeout=self._timeout) with a default of e.g. 30 seconds.
  • TypeScript: Pass an AbortController signal: signal: AbortSignal.timeout(this.timeoutMs ?? 30_000).

Expose the timeout as a constructor parameter with a sensible default.


[severity-06] wallets_config.json file permissions not restricted after Privy wallet write

Property Value
Severity Major
Category Security
File packages/typescript/src/core/config.ts : Lines 105-114; packages/python/src/agent_wallet/core/config.py : Lines 99-131

Description:

save_config / _write_config write wallets_config.json and attempt a chmod 0o600. However, the Privy app_secret is now stored in this file. If chmod silently fails (e.g., on FAT-formatted volumes or Windows), the file may be world-readable. This issue exists for raw_secret wallets too (a pre-existing concern), but is now more severe because app_secret is a cloud credential giving programmatic access to every Privy wallet associated with that app_id — not just the one wallet configured.

Additionally, the TypeScript saveConfig wraps chmodSync in a bare catch {} with a comment // ignore on platforms without chmod support. Silently ignoring a failed permission restriction on a file containing a credential is a security concern that should at least be logged.

Recommendation:

  1. If chmod fails, emit a warning to stderr rather than silently swallowing it.
  2. In CLI output, warn users when storing Privy credentials that the wallets_config.json contains a sensitive API secret and recommend filesystem-level access controls.
  3. Long-term: encrypt app_secret (see finding severity-01).

[severity-07] PrivyConfigResolver does not read from environment variables, creating inconsistency

Property Value
Severity Minor
Category Correctness / Design
File packages/typescript/src/core/providers/privy-config.ts : Lines 44-52; packages/python/src/agent_wallet/core/providers/privy_config.py : Lines 46-52

Description:

All other wallet credential sources in this SDK support environment variable fallback (e.g., AGENT_WALLET_PASSWORD, AGENT_WALLET_PRIVATE_KEY, AGENT_WALLET_MNEMONIC). PrivyConfigResolver only accepts an explicit source object. There is no way to provide AGENT_WALLET_PRIVY_APP_ID, AGENT_WALLET_PRIVY_APP_SECRET, or AGENT_WALLET_PRIVY_WALLET_ID environment variables. This creates inconsistency and prevents "config-free" usage patterns (e.g., in container environments where secrets are injected as env vars).

Recommendation:

Add environment variable fallback to PrivyConfigResolver.merge():

private merge(): PrivyConfigSource {
    const source = normalizeSource(this.source)
    return {
        app_id: source.app_id ?? cleanEnvValue(process.env.AGENT_WALLET_PRIVY_APP_ID),
        app_secret: source.app_secret ?? cleanEnvValue(process.env.AGENT_WALLET_PRIVY_APP_SECRET),
        wallet_id: source.wallet_id ?? cleanEnvValue(process.env.AGENT_WALLET_PRIVY_WALLET_ID),
    }
}

Document the new env vars in CLAUDE.md and README.md.


[severity-08] normalizeTransactionPayload always wraps in { transaction: ... } even for flat payloads

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Lines 171-236; packages/python/src/agent_wallet/core/adapters/privy.py : Lines 155-219

Description:

When payload is a flat viem-style transaction (no transaction key), hasTransaction is false, and the function returns { transaction: normalized } — correctly wrapping into Privy's expected shape. However when payload already has a transaction key, it returns { ...payload, transaction: normalized }, which preserves all original top-level keys. This means stray top-level keys (e.g., _meta, chainId at root level alongside a transaction object) survive into the Privy request. While this is unlikely to cause failures today, it leaks unintended fields to the Privy API.

Recommendation:

For the hasTransaction=true path, only pass through known top-level Privy envelope keys rather than spreading ...payload:

if (hasTransaction) {
    return { method: payload.method, params: normalized }  // or just { transaction: normalized }
}

[severity-09] _recover_tron_v in Python silently swallows all exceptions in the loop

Property Value
Severity Minor
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 260-271

Description:

Same concern as finding severity-04 but for the Python implementation. The bare except Exception: continue in _recover_tron_v masks any unexpected error in the tronpy signature recovery path.

Code:

for v in (0, 1):
    try:
        sig = keys.Signature(signature_rs + bytes([v]))
        pub = sig.recover_public_key_from_msg_hash(digest)
        if pub.to_base58check_address() == address:
            return v
    except Exception:
        continue

Recommendation:

Log or re-raise unexpected exceptions:

except (ValueError, Exception) as e:
    if isinstance(e, ValueError):
        continue
    raise

[severity-10] Python __init__.py does not export PrivyAdapter, PrivyClient, or PrivyConfigError

Property Value
Severity Minor
Category Quality / API
File packages/python/src/agent_wallet/__init__.py : Lines 1-63

Description:

The TypeScript index.ts exports PrivyAdapter, PrivyClient, PrivyConfigResolver, PrivyConfigError, PrivyRequestError, PrivyRateLimitError, and PrivyAuthError. The Python __init__.py exports none of these, despite the __all__ list containing references to symbols that aren't imported (EvmSigner, LocalSigner, etc. are deferred). Users relying on from agent_wallet import PrivyAdapter will get an AttributeError since __getattr__ does not handle PrivyAdapter.

Code:

# __init__.py — __getattr__ handles LocalSigner, EvmSigner, TronSigner but NOT:
# PrivyAdapter, PrivyClient, PrivyConfigResolver, PrivyConfigError, PrivyRequestError, etc.

Recommendation:

Add PrivyAdapter and the Privy error classes to __getattr__ (or directly import them at module level):

if name == "PrivyAdapter":
    from agent_wallet.core.adapters.privy import PrivyAdapter
    return PrivyAdapter
if name in ("PrivyConfigError", "PrivyRequestError", "PrivyRateLimitError", "PrivyAuthError"):
    from agent_wallet.core.errors import ...
    return ...

Also update __all__ to match the TypeScript public API.


[severity-11] generatePassword in TypeScript CLI has modular bias in character selection

Property Value
Severity Minor
Category Security
File packages/typescript/src/delivery/cli.ts : Lines 325-354

Description:

The pick helper uses charset[randomBytes(1)[0] % charset.length]. For charsets whose length is not a power of 2, this produces a slight modular bias (some characters are slightly more likely). For example upper has 26 characters; randomBytes(1)[0] is in [0,255], and 256 % 26 = 22, making the first 22 characters fractionally more likely than the last 4. While this is a negligible bias for password generation (well within acceptable randomness), it is a known pattern to flag in security-sensitive code.

Recommendation:

Use rejection sampling to eliminate bias:

const pick = (charset: string, count: number): string[] => {
    const result: string[] = []
    while (result.length < count) {
        const byte = randomBytes(1)[0]
        if (byte < Math.floor(256 / charset.length) * charset.length) {
            result.push(charset[byte % charset.length])
        }
    }
    return result
}

[severity-12] TRON v byte recovery in TypeScript PrivyAdapter uses v + 27 but local TronSigner also uses v + 27

Property Value
Severity Suggestion
Category Correctness / Documentation
File packages/typescript/src/core/adapters/privy.ts : Lines 148-160

Description:

tronSignHash computes the final v value as (recovery + 27). This is consistent with how TronSigner.signDigest produces its v. However, Tron's native signature format actually stores v differently in various contexts — TRONWEB uses v = recovery (not +27) in some paths. The test (routes TRON signing through raw_sign and appends v) validates that the output matches what secp256k1.sign().recovery + 27 produces, which is the same convention as the local signer. This is probably correct, but the rationale for +27 vs raw recovery bit is not documented.

Recommendation:

Add a comment explaining why v + 27 is used (for compatibility with which specific Tron transaction broadcast format) and add a reference to Tron documentation or the comparison example script.


[severity-13] Python PrivyClient.retries allows _FakeResponse to simulate 429 responses without the HTTPError path; real 429 responses from urllib come as HTTPError

Property Value
Severity Suggestion
Category Testing
File packages/python/src/agent_wallet/core/clients/privy.py : Lines 73-91; packages/python/tests/test_privy_client.py : Lines 68-88

Description:

In PrivyClient._request, a 429 from urlopen arrives as an HTTPError, but the test's FakeResponse returns a response object with status=429 (simulating the successful-context-manager path). The real code would receive an HTTPError on 429, but the test exercises the if status == 429 branch that is only reachable when the response does not raise HTTPError. This means the retry logic is only tested via the fake 429 response path, which diverges from production behavior where urlopen raises HTTPError for 4xx/5xx responses.

Code:

# In _request():
except HTTPError as exc:
    status = exc.code
    ...
# then falls through to:
if status == 429:
    ...   # This branch IS reachable (from HTTPError path)

But the test patches urlopen to return a non-raising _FakeResponse(429, ...). The _FakeResponse.__enter__ returns the object successfully, so status is set from response.status — bypassing the HTTPError branch entirely.

Recommendation:

Add a test variant that raises HTTPError(code=429) to verify the retry logic works through the exception-handling path, not just the direct status-code path.


[severity-14] Missing test: TRON signTransaction via PrivyAdapter is not tested end-to-end

Property Value
Severity Suggestion
Category Testing
File packages/typescript/tests/privy-adapter.test.ts; packages/python/tests/test_privy_adapter.py

Description:

The adapter tests cover signMessage (EVM/TRON), signTransaction (EVM), signTypedData (EVM/TRON), and signRaw (unsupported). There is no test for TRON signTransaction via PrivyAdapter.tronSignTransaction. This is a non-trivial path: it parses raw_data_hex, computes or validates a txID, signs via raw_sign, and appends the recovered v. Both the txID-provided and txID-computed branches are untested.

Recommendation:

Add tests for:

  1. tronSignTransaction with an explicit txID in the payload.
  2. tronSignTransaction with raw_data_hex only (computed txID).
  3. Invalid raw_data_hex / invalid txID format (error path).

4. Positive Observations

  1. Consistent dual-language implementation. Every new TypeScript construct has a Python mirror, and vice versa. The mirrored tests (e.g., test_privy_adapter.py vs privy-adapter.test.ts) cover the same scenarios and both inject fake clients, demonstrating disciplined cross-language parity.

  2. Dependency injection for testability. PrivyAdapter accepts a PrivyClient | None (Python) or a duck-typed client object (TypeScript), and PrivyClient accepts a sleep function. This enables hermetic unit tests without any network calls or mocking of globals.

  3. Error hierarchy is well-structured. The four new Privy-specific error classes (PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError) all extend the existing WalletError base, preserving the caller-visible error hierarchy. Callers can distinguish auth failures (non-retryable) from rate limits (retryable) without parsing strings.

  4. Recovery ID computation is correct and tested. The tronSignHashrecoverTronRecoveryId flow is cryptographically sound (iterate over {0,1}, recover public key, compare derived Tron address). The tests construct a real secp256k1 keypair, sign a hash locally, and verify the recovered address matches, providing solid coverage.

  5. PrivyConfig is validated before use. PrivyConfigResolver.resolve() calls requiredMissing() before constructing the config object, ensuring the adapter never receives empty credentials. The test test_missing_required_fields_redacts_secrets verifies the error message does not leak the app_secret, which is good defensive security practice.

  6. Retry with backoff on rate limits. The capped exponential back-off (min(1000ms, 200*attempt) in TS; min(1.0s, 0.2*attempt) in Python) with a configurable retries limit is a sensible resilience pattern for a rate-limited external API.

  7. Wallet cache invalidation. ConfigWalletProvider.removeWallet() evicts the wallet from the in-memory cache to prevent stale references after removal. This subtle correctness detail is easy to miss and was handled properly.


5. Checklist Results

Dimension Status Notes
Correctness & Logic Partial TRON message hash divergence (TS vs Python), recovery error swallowing
Security Fail app_secret stored in plaintext; chmod failure silently ignored
Performance Partial Blocking HTTP in async Python context; no request timeout
Code Quality Pass Clean architecture; mirrors established patterns; good error types
Testing Partial TRON signTransaction untested; retry path tested via wrong code path in Python
Documentation Pass how-to-add-privy-wallet.md added; AGENTS.md added; README.md updated

6. Review Verdict

Decision: Request Changes

Rationale

The PR delivers a well-structured Privy integration that faithfully mirrors the existing architecture. The code quality is high: the adapter follows established patterns, error handling is deliberate, and test coverage is reasonable for a new feature.

However, two issues require resolution before merging:

  1. Critical security issue (severity-01): The app_secret is a cloud credential that gives access to all wallets under the Privy application. Storing it in plaintext in wallets_config.json is inconsistent with the SDK's stated "secure signing" philosophy and its own encryption infrastructure. Either the secret must be encrypted at rest, or the PR must explicitly document this as a known limitation and implement an env-var alternative.

  2. Major correctness issue (severity-02): The Python PrivyClient is synchronous and will block the asyncio event loop on every Privy API call. Given that PrivyAdapter methods are all async, this will cause deadlocks or severe degradation in any real async caller.

Additionally, the TRON message hash divergence (severity-03) between TypeScript and Python is a functional correctness bug that would produce different signatures for the same input across languages, breaking cross-language interoperability.

Required Before Merge

  • Encrypt app_secret at rest or add explicit env-var alternative + documentation warning (severity-01)
  • Wrap PrivyClient._request in asyncio.to_thread or switch to async HTTP (severity-02)
  • Align TRON signMessage hash strategy between Python and TypeScript (severity-03)

Recommended Before Merge

  • Add request timeout to both Python and TypeScript clients (severity-05)
  • Add PrivyAdapter and Privy error types to Python __init__.py exports (severity-10)
  • Add TRON signTransaction adapter tests (severity-14)

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 1, 2026

Code Review Report

Project: Agent-Wallet (bankofai-agent-wallet / @bankofai/agent-wallet)
PR: main -> feat/privy-support
Review Date: 2026-04-01
Reviewer: AI Code Reviewer (Code Review Skill v1.0.0)


PR Overview

Branch Information

Property Value
From Branch main
To Branch feat/privy-support
Commits 8
Files Changed 116
Lines Added +9704
Lines Removed -922

Commit History

Hash Message
017632f fix: ci test no color
8ad9238 fix: client error parse
a7c9aac chore: update v2.4.0
9bfe661 feat: change-password no interact mode
502b4ed feat: resolve-address comamnd
c287ebf feat: resolve address
5f58664 feat: optimize hierachical command and optimize remove command ux
c4fba9a feat: privy support

Review Summary

Verdict

Verdict: Request Changes

Findings at a Glance

Critical Major Minor Suggestion
Count 0 4 5 5

Summary

This PR introduces Privy API-backed wallet support across both the TypeScript and Python packages, brings SignOptions to every signing interface for authorization-signature pass-through, refactors utility helpers into dedicated utils/ modules, relaxes the network requirement in several places to accommodate network-agnostic Privy wallets, adds a resolve-address CLI command, and lowers scrypt work-factor during test execution for faster CI runs. The feature set is well-scoped and the dual-language parity is tight. Tests for the new Privy adapter and client are thorough.

Four major issues require attention before merging. The Python PrivyAdapter calls self._client.get_wallet() — a synchronous method — without await, but the method is defined as synchronous, meaning it will not run in an async loop at all in real usage (it blocks the event loop). The TypeScript PrivyClient.rpc() body has inconsistent indentation for the method and params keys, a minor cosmetic problem but it violates the project's formatter config. The _scrypt_params() helper in Python reads PYTEST_CURRENT_TEST to reduce the work-factor at test time; this sentinel leaks test configuration into production code and can silently produce keystores with weaker-than-intended parameters if that environment variable is accidentally present in production. The TypeScript equivalent uses process.env.VITEST for the same purpose with the same concern. Additionally, resolveNetwork in the new utility modules now returns string | undefined instead of throwing, silently propagating undefined to parseNetworkFamily() which immediately throws with a generic "network is required" error rather than the more helpful context that was lost.

The overall quality of the implementation is high. The Privy adapter correctly recovers the TRON recovery bit, the payload-normalisation helpers handle both camelCase and snake_case inputs, wallet config caching has been upgraded to a proper three-level keyed map to support privy entries without a network dimension, and all new public symbols are properly exported from the package entry-points.


Change Summary

1. Privy Wallet Adapter (core/adapters/privy.ts, core/adapters/privy.py)

New PrivyAdapter class implementing Wallet and Eip712Capable. Delegates EVM signing to Privy RPC methods and implements a full TRON signing path that recovers the secp256k1 recovery bit locally by trying v=0 then v=1 against the cached Privy address.

2. Privy HTTP Client (core/clients/privy.ts, core/clients/privy.py)

Thin HTTP wrapper around https://api.privy.io. Handles Basic auth, privy-app-id header, privy-authorization-signature header, rate-limit retry with exponential backoff, and error classification into PrivyAuthError, PrivyRateLimitError, and PrivyRequestError.

3. Privy Config Resolver (core/providers/privy-config.ts, core/providers/privy_config.py)

Validates and resolves app_id, app_secret, wallet_id from a config source map, normalising empty strings to missing.

4. SignOptions on Every Signing Interface

Adds an optional SignOptions (containing authorizationSignature) to all four methods of Wallet and Eip712Capable. Existing local signers accept and ignore the option; PrivyAdapter threads it into the privy-authorization-signature header.

5. Utility Module Extraction

parseNetworkFamily, resolveNetwork, decodePrivateKey, deriveKeyFromMnemonic, cleanEnvValue, firstEnv, parseAccountIndex, and stripHexPrefix are moved from their scattered locations into core/utils/{network,keys,env,hex}.ts|py. Related private constants for env key names are promoted to core/base.ts|py for shared use.

6. Relaxed network Requirement

LocalSigner, LocalSecureSigner, RawSecretSigner, createAdapter, and resolveNetwork() now accept network?: string (i.e. optional/undefined) to allow Privy wallets — which are network-agnostic — to be stored in the config without a network field.

7. KV-Store Scrypt Parameter Flexibility

_derive_key / deriveKey now read kdfparams from the stored keystore instead of the compile-time constants when decrypting, and a test-detection hook reduces N during test runs. Encrypted keystores also persist the actual parameters used, matching EIP-55/V3 standard expectations.

8. CLI Enhancements

New resolve-address command, Privy wallet creation flow in quick-start and add-wallet, requireInteractive guard on all prompts, promptRequired helper, and a new ANSI_YELLOW escape code.

9. Address Resolution Module (core/address-resolution.ts, core/address_resolution.py)

resolveWalletAddresses() returns an AddressResolutionResult containing either a single Privy address or the dual EVM/TRON pair for local wallets.


Detailed Findings

[major-01] Python PrivyAdapter calls synchronous get_wallet inside async methods without blocking-safe pattern

Property Value
Severity Major
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 36, 101

Description:
get_address() and _get_chain_type() call self._client.get_wallet() which is a plain synchronous method implemented in PrivyClient. This is fine from a correctness standpoint because the call is not awaited (it is not a coroutine), but it means the HTTP request — which uses urllib.request.urlopen — runs synchronously and blocks the event loop when called inside an async def. In any asyncio context (including all async test runners and real agent usage), this will block the event loop for the duration of the HTTP round-trip, potentially starving other coroutines. The TypeScript client is correctly async/await, making this an asymmetry between the two implementations.

Code:

async def get_address(self) -> str:
    if self._cached_address:
        return self._cached_address
    payload = self._client.get_wallet(self._wallet_id)   # blocks event loop
    ...

async def _get_chain_type(self) -> str:
    if self._cached_chain_type:
        return self._cached_chain_type
    payload = self._client.get_wallet(self._wallet_id)   # blocks event loop

Recommendation:
Either run the blocking call in a thread pool executor:

import asyncio
loop = asyncio.get_event_loop()
payload = await loop.run_in_executor(None, self._client.get_wallet, self._wallet_id)

Or convert PrivyClient._request() and its callers to async/await using aiohttp or httpx. The same applies to sign_transaction, sign_message, sign_typed_data, and _tron_sign_hash which all call synchronous rpc() / raw_sign() methods while themselves being async def. Caching partially mitigates the get_wallet calls but does not protect the first call or any signing call.


[major-02] Test-time scrypt downgrade leaks into production via environment sentinel

Property Value
Severity Major
Category Security
File packages/python/src/agent_wallet/local/kv_store.py : Lines 40-52, packages/typescript/src/local/kv-store.ts : Lines 43-60

Description:
_scrypt_params() (Python) and getScryptParams() (TypeScript) check os.environ.get("PYTEST_CURRENT_TEST") / process.env.VITEST respectively, and when present reduce N from 262144 to 16384. The intent is fast CI. The problem is that production environment discovery is now entangled with test-framework sentinels. If PYTEST_CURRENT_TEST is ever set in a staging/production shell (e.g. by a misconfigured CI agent that persists env vars, or through user error), wallets will silently be encrypted with a weaker work-factor. The mismatch would not be detected at runtime because decryption reads N from the stored keystore. Additionally, AGENT_WALLET_TEST_SCRYPT_N with a value > 1 overrides N entirely with no lower bound other than > 1, meaning N=2 is accepted.

Code:

def _scrypt_params() -> tuple[int, int, int, int]:
    ...
    if os.environ.get("PYTEST_CURRENT_TEST"):
        return TEST_SCRYPT_N, DEFAULT_SCRYPT_R, DEFAULT_SCRYPT_P, DEFAULT_SCRYPT_DKLEN

Recommendation:
Remove the PYTEST_CURRENT_TEST / VITEST environment checks from production code. Instead, inject scrypt params as a constructor argument to SecureKVStore / the encrypt/decrypt helpers, and configure reduced params only in tests via the constructor. This is a standard dependency-injection approach that keeps test concerns out of production paths. For AGENT_WALLET_TEST_SCRYPT_N, enforce a minimum value of at least 4096 and document it as a test-only escape hatch.


[major-03] resolveNetwork silently returns undefined, breaking the parseNetworkFamily error message chain

Property Value
Severity Major
Category Correctness
File packages/typescript/src/core/utils/network.ts : Lines 11-17, packages/python/src/agent_wallet/core/utils/network.py : Lines 20-24

Description:
The old resolveNetwork() helpers in config-provider.ts and env-provider.ts threw descriptive Error("network is required") when neither explicit nor default network was available. The refactored version in utils/network.ts returns undefined silently. When this undefined reaches parseNetworkFamily(undefined) the error thrown is "network is required" with no indication of which call site failed or which wallet was involved. More importantly, RawSecretSigner calls parseNetworkFamily(network) immediately in its constructor, so the error is still raised, but downstream code that relied on the old contract (e.g. places that called resolveNetwork and expected a string) may pass undefined to APIs that do not expect it.

Code:

export function resolveNetwork(
  explicit: string | undefined,
  providerDefault: string | undefined,
): string | undefined {           // was: throws if both undefined
  if (explicit) return explicit
  if (providerDefault) return providerDefault
  return undefined                // silent; old code threw here
}

Recommendation:
Maintain two variants: resolveNetwork returning string | undefined for contexts where undefined is valid (Privy wallets), and requireNetwork that throws with a contextual message for local signer paths. Update call sites in ConfigWalletProvider.getActiveWallet() and EnvWalletProvider.getActiveWallet() to use requireNetwork when constructing local signers.


[major-04] TypeScript PrivyClient.rpc() body has misaligned indentation

Property Value
Severity Major
Category Code Quality
File packages/typescript/src/core/clients/privy.ts : Lines 64-68

Description:
The rpc() method's request body object literal has method and params keys indented 6 spaces instead of the project standard of 2-space nesting relative to the enclosing block. The rawSign() method directly below uses correct 8-space (4-level) indentation. This will fail prettier --check (the project has a format:check target) and is inconsistent with every other object literal in the file.

Code:

  async rpc(...): Promise<PrivyRpcResponse> {
    return this.request(
      'POST',
      `/v1/wallets/${walletId}/rpc`,
      {
      method,    // <-- should be indented 6 spaces (2 more than opening brace)
      params,
      },

Recommendation:

      {
        method,
        params,
      },

[minor-01] TRON recovery-bit calculation uses v + 27 in TypeScript but raw v in Python

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/adapters/privy.ts : Line 158, packages/python/src/agent_wallet/core/adapters/privy.py : Line 152

Description:
In tronSignHash() (TypeScript) the recovery id v is adjusted by +27 before being appended to the signature: (v + 27).toString(16). In _tron_sign_hash() (Python), the raw v value (0 or 1) is appended directly: f"{v:02x}". TRON's signature format for the Privy raw_sign path uses Ethereum-style v values of 27 or 28 (legacy). The Python implementation will produce 00 or 01 as the v byte, while the TypeScript implementation produces 1b or 1c. Only one of these is correct for the Privy/TRON contract. This breaks the cross-language consistency guarantee stated in CLAUDE.md.

Code:

// TypeScript – line 158
const vHex = (v + 27).toString(16).padStart(2, '0')
# Python – line 152
return (sig_hex + f"{v:02x}").lower()   # missing + 27

Recommendation:
Align Python with TypeScript. Change the Python return to:

return (sig_hex + f"{v + 27:02x}").lower()

Add a cross-language integration test asserting both implementations produce identical signatures for the same TRON hash.


[minor-02] PrivyConfigResolver does not validate config from environment variables, only from explicit source

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/providers/privy-config.ts : Lines 16-50, packages/python/src/agent_wallet/core/providers/privy_config.py : Lines 21-57

Description:
PrivyConfigResolver accepts only a single source dict / mapping. There is no env-var fallback path (e.g. PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID). A user who wants to avoid storing the app_secret in wallets_config.json cannot do so. The spec document (kiro/specs/privy-adapter-providers/requirements.md) mentions "env var override" as a requirement. While omitting env-var support may be intentional for the MVP, it leaves the app_secret — a sensitive credential — necessarily stored in plaintext in the config file. This is documented as a known limitation but warrants a finding for visibility.

Recommendation:
Consider adding an env-var layer (PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID) to PrivyConfigResolver.merge() as a fallback after the explicit source, consistent with how EnvWalletProvider handles private keys. At minimum, add a warning in the CLI output when a Privy wallet is stored to config, noting the plaintext app_secret.


[minor-03] normalizeTypedDataPayload mutates the input dict in Python

Property Value
Severity Minor
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 237-242

Description:
_normalize_typed_data_payload(data) calls typed.pop("primaryType") and assigns primary_type. Because payload = data if "typed_data" in data else {"typed_data": data}, when data already contains "typed_data", payload is the same object as data. Mutating typed (a value inside data) changes the caller's dict, which is unexpected for a function named "normalize". The TypeScript version has the same mutation pattern on line 261-263.

Code:

def _normalize_typed_data_payload(data: dict) -> dict:
    payload = data if "typed_data" in data else {"typed_data": data}
    typed = payload.get("typed_data")
    if isinstance(typed, dict) and "primaryType" in typed and "primary_type" not in typed:
        typed["primary_type"] = typed.pop("primaryType")  # mutates caller's dict
    return payload

Recommendation:
Return a deep copy or build a new dict for the typed_data value rather than mutating in place:

if isinstance(typed, dict) and "primaryType" in typed and "primary_type" not in typed:
    typed = {**typed, "primary_type": typed["primaryType"]}
    del typed["primaryType"]
    return {**payload, "typed_data": typed}

[minor-04] EnvWalletProvider constructor silently ignores network=None when env has no key

Property Value
Severity Minor
Category Correctness
File packages/typescript/src/core/providers/env-provider.ts : Lines 46-56

Description:
The refactored resolveEnvWallet() calls resolveNetwork(explicitNetwork, providerDefault) which can return undefined. That undefined is stored as network inside EnvWalletResolved and eventually passed to RawSecretSigner(params, undefined), which in turn calls parseNetworkFamily(undefined) and throws "network is required". This is correct behaviour, but the error occurs late (when createEnvAdapter is called) rather than at EnvWalletProvider construction time, making the error harder to attribute. The Python path has the same late-error pattern.

Recommendation:
Call parseNetworkFamily eagerly in resolveEnvWallet so that the "network is required" error is thrown with the env-provider context clearly visible in the stack trace.


[minor-05] _recover_tron_v in Python silently swallows all exceptions per recovery attempt

Property Value
Severity Minor
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 260-271

Description:
The recovery loop catches Exception broadly on each attempt. If both v=0 and v=1 fail due to an unrelated error (corrupt signature bytes, wrong hash length, tronpy library error), the loop exhausts silently and raises UnsupportedOperationError("Unable to derive recovery id"). This masks the actual root cause. The TypeScript version has the same silent-catch pattern.

Code:

for v in (0, 1):
    try:
        sig = keys.Signature(signature_rs + bytes([v]))
        pub = sig.recover_public_key_from_msg_hash(digest)
        if pub.to_base58check_address() == address:
            return v
    except Exception:   # swallows all errors
        continue

Recommendation:
Accumulate exceptions and include the last error in the UnsupportedOperationError message:

last_exc: Exception | None = None
for v in (0, 1):
    try:
        ...
    except Exception as exc:
        last_exc = exc
        continue
raise UnsupportedOperationError(
    f"Unable to derive recovery id for TRON signature: {last_exc}"
)

[suggestion-01] PrivyAdapter in Python stores app_id and app_secret as constructor args but discards them

Property Value
Severity Suggestion
Category Code Quality
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 17-29

Description:
The PrivyAdapter.__init__ receives app_id and app_secret only to pass them to PrivyClient() if no client is provided. After construction they are not retained on self. The TypeScript version stores a PrivyConfig object instead. The Python version's pattern is functional but means that when an external client is injected (as in all tests), the app_id / app_secret args are silently ignored. This is slightly surprising and differs from the TypeScript constructor signature.

Recommendation:
Either store app_id/app_secret on the instance for debugging/serialisation purposes, or follow the TypeScript pattern and accept a PrivyConfig dataclass directly.


[suggestion-02] PrivyConfigResolver in TypeScript does not support env-var override (asymmetry with Python)

Property Value
Severity Suggestion
Category Docs
File packages/typescript/src/core/providers/privy-config.ts

Description:
The Python PrivyConfigResolver and TypeScript PrivyConfigResolver only accept an explicit source dict. The CLAUDE.md dual-language parity requirement means any future change to either must be mirrored. A comment should document that env-var support is intentionally deferred.

Recommendation:
Add a // NOTE: env-var override not yet supported; tracked in issue #XYZ comment above merge() in both implementations.


[suggestion-03] _to_hex_value in Python does not handle float inputs

Property Value
Severity Suggestion
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 222-234

Description:
_to_hex_value(value) has branches for str and int but not for float. The TypeScript toHexValue() explicitly handles Number.isFinite(value) and truncates. If a Python caller passes a float (e.g. gas_price: 1.5e9 from a JSON payload without strict typing), isinstance(value, int) will be False (Python float is not int), and the value will pass through unchanged, resulting in a non-hex string reaching the Privy API.

Recommendation:
Add a float branch analogous to the TypeScript implementation:

if isinstance(value, float) and value == int(value):
    return hex(int(value))

[suggestion-04] TypeScript PrivyClient missing user-agent header (Python has it, TypeScript does not)

Property Value
Severity Suggestion
Category Code Quality
File packages/typescript/src/core/clients/privy.ts : Lines 125-136

Description:
The Python PrivyClient._headers() sets "user-agent": "agent-wallet-python". The TypeScript buildHeaders() does not set a user-agent. This is a minor cosmetic asymmetry but may matter for Privy's server-side logging or rate-limit differentiation.

Recommendation:
Add 'user-agent': 'agent-wallet-typescript' to buildHeaders().


[suggestion-05] .kiro/ spec and design documents committed to the repo may not belong in version control

Property Value
Severity Suggestion
Category Docs
File .kiro/ (33 new files)

Description:
This PR adds 33 files under .kiro/ including design documents, requirements, research notes, and task lists generated by the Kiro AI design tool. These are internal planning artifacts and not referenced by any code. Committing them adds noise to the repo history, may include sensitive design details, and may conflict with .gitignore conventions (the PR also adds .codex/ to .gitignore, suggesting tool-generated directories are being excluded). If these files are intended as living documentation they should be in doc/ with an explanation in README.md.

Recommendation:
Either add .kiro/ to .gitignore (parallel to .claude/ and .codex/ which are already excluded), or move relevant content to doc/ with a clear audience note.


Positive Observations

Area Observation
TRON signing correctness recoverTronRecoveryId correctly iterates v ∈ {0, 1}, recovers the public key, derives the TRON address using keccak256 + Base58Check, and compares against the cached wallet address — a precise implementation of TRON's signature scheme.
Payload normalisation normalizeTransactionPayload handles both snake_case (Privy-native) and camelCase (viem-native) inputs elegantly, converting numeric fields to hex strings in a single pass. The pass-through of unknown keys prevents silent data loss.
Wallet cache upgrade Upgrading the ConfigWalletProvider cache from Map<string, Wallet> to `Map<string, Map<WalletType, Map<string
Test coverage New privy-adapter.test.ts, privy-client.test.ts, test_privy_adapter.py, test_privy_client.py cover happy paths, error paths, caching, and the full TRON signing round-trip with a real key fixture.
KV-store read-back of params Fixing decryptBytes / _decrypt_bytes to read scrypt parameters from the stored keystore rather than compile-time constants is a correctness improvement that aligns with EIP-55/V3 and allows future work-factor migration.
requireInteractive guard The new requireInteractive / promptInput wrappers in cli.ts provide a clean, centralised mechanism for non-interactive mode detection, which was previously missing and is needed for the change-password --password flag and CI use cases.
Utility module extraction Moving parseNetworkFamily, decodePrivateKey, deriveKeyFromMnemonic, and env helpers into core/utils/ reduces coupling and eliminates duplicated implementations that existed across cli.ts, raw-secret.ts, and wallet_builder.py.
Dual-language parity The Python and TypeScript implementations mirror each other closely, as required by CLAUDE.md. API shapes, error class names, and signing flows are consistent between the two languages.

Checklist Results

Category Items Checked Pass Fail N/A Notes
Correctness 8 4 4 0 TRON v-recovery asymmetry (TS vs Py), blocking HTTP in async context, silent network undefined, typed-data mutation
Security 5 3 2 0 app_secret in plaintext config; test-time scrypt downgrade via env sentinel
Performance 3 2 1 0 Synchronous HTTP in async context blocks event loop
Code Quality 6 4 2 0 Indentation issue in PrivyClient.rpc(); user-agent header asymmetry
Testing 5 5 0 0 Good coverage of new adapter, client, config resolver, and CLI paths
Documentation 4 2 1 1 .kiro/ planning artifacts committed; no env-var override caveat documented
Compatibility 3 3 0 0 SignOptions is backward-compatible (optional param); WalletType enum extended safely
Observability 2 1 1 0 Silent exception swallowing in TRON recovery loop hides root causes

Disclaimer

This is an automated code review. It supplements but does not replace human review.


Report generated by Code Review Skill v1.0.0
Date: 2026-04-01

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 1, 2026

Audit Report: feat/privy-support

Repository: agent-wallet
Branch: feat/privy-supportmain
Date: 2026-04-01
Reviewer: Claude Sonnet 4.6


1. PR Overview

Property Value
Base Branch remotes/origin/main
Head Branch remotes/origin/feat/privy-support
Commit Count 9
Files Changed 116
Lines Added ~9,714
Lines Removed ~922

Commit History

Hash Message
c4e24c7 fix: ci test no color 2
017632f fix: ci test no color
8ad9238 fix: client error parse
a7c9aac chore: update v2.4.0
9bfe661 feat: change-password no interact mode
502b4ed feat: resolve-address command
c287ebf feat: resolve address
5f58664 feat: optimize hierarchical command and optimize remove command ux
c4fba9a feat: privy support

2. Change Summary

Group 1: Privy Wallet Adapter (Core Feature)

New PrivyAdapter class in both TypeScript (src/core/adapters/privy.ts) and Python (src/agent_wallet/core/adapters/privy.py) implementing the Wallet and Eip712Capable interfaces. Routes EVM signing through Privy's RPC API and TRON signing through a local v-recovery flow on top of Privy's raw_sign endpoint. Includes payload normalization logic to translate camelCase/snake_case field names and convert integer fields to hex strings.

Group 2: Privy HTTP Client

New PrivyClient in both languages (src/core/clients/privy.ts, src/agent_wallet/core/clients/privy.py). Implements Basic-auth against https://api.privy.io, exposes getWallet, rpc, and rawSign methods, and handles rate-limit (429) retries with exponential backoff.

Group 3: Privy Configuration Resolver

PrivyConfigResolver (src/core/providers/privy-config.ts|py) validates and resolves app_id, app_secret, and wallet_id from a config source, with whitespace normalization and clear error messages on missing fields.

Group 4: Config Schema Extension

Both config.ts and config.py were updated to add PrivyWalletParams and the privy wallet type. Zod (TS) and Pydantic (Python) schemas now accept { type: "privy", params: { app_id, app_secret, wallet_id } } entries in wallets_config.json.

Group 5: Provider and Resolver Updates

ConfigWalletProvider, WalletBuilder, and the resolver chain were updated to instantiate PrivyAdapter for privy-type wallets. Privy wallets skip the resolveNetwork step since the chain is determined by the Privy API.

Group 6: Address Resolution

New resolveWalletAddresses helpers (src/core/address-resolution.ts|py) that return a single canonical address for Privy wallets and the EVM+TRON pair for local wallets. Used by the new resolve-address CLI command.

Group 7: Utility Refactoring

Python's single core/utils.py was refactored into a core/utils/ package with submodules: env.py, hex.py, keys.py, network.py. TypeScript received parallel new utility files.

Group 8: CLI Enhancements

Both CLIs gained add privy, resolve-address, and change-password (non-interactive mode) commands. The Python CLI (delivery/cli.py) grew by ~800 lines. Significant UX improvements including hierarchical subcommands.

Group 9: Tests

Comprehensive new test files: test_privy_adapter.py, test_privy_client.py, test_privy_config.py, privy-adapter.test.ts, privy-client.test.ts, privy-config.test.ts, and expanded existing test suites. Total test additions across both languages exceed 1,500 lines.

Group 10: Documentation

New doc/how-to-add-privy-wallet.md, updated README.md, doc/getting-started.md, and AGENTS.md. Added .kiro/ directory with design specs, steering principles, and task generation rules.


3. Detailed Findings

Critical Findings

[C-01] Python TRON Privy Signature v-Byte Incompatible with TypeScript

Property Value
Severity Critical
Category Correctness / API Contract
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 151-152 vs packages/typescript/src/core/adapters/privy.ts : Lines 157-158

Description: The TypeScript PrivyAdapter.tronSignHash appends v + 27 to the signature (producing 1b or 1c in hex), while the Python PrivyAdapter._tron_sign_hash appends raw v (producing 00 or 01). These are two distinct signature encodings. TRON nodes, off-chain verifiers, and cross-language consumers that receive a 65-byte signature will interpret the last byte differently. Any system mixing Python and TypeScript Privy TRON signing will produce non-interoperable signatures.

Code:

TypeScript (privy.ts, line 158):

const vHex = (v + 27).toString(16).padStart(2, '0')
return `${sigHex}${vHex}`

Python (privy.py, line 152):

return (sig_hex + f"{v:02x}").lower()
# v is 0 or 1, so appends "00" or "01"

The TypeScript TronSigner's own signDigest (line 135 of tron.ts) also uses v + 27, confirming TS convention is 1b/1c.

Recommendation: Align the Python implementation to match the TypeScript convention:

return (sig_hex + f"{v + 27:02x}").lower()

Alternatively, if tronpy's own output uses 0/1 and that is the accepted TRON on-chain format, then TypeScript must be updated to match. The decision must be deliberate and documented. A cross-language integration test asserting signature byte-equality would prevent future regression.


[C-02] Python ConfigWalletProvider Cache Eviction Calls startswith on a Tuple

Property Value
Severity Critical
Category Correctness / Runtime Error
File packages/python/src/agent_wallet/core/providers/config_provider.py : Lines 99-102

Description: self._wallets is typed as dict[tuple[str, WalletType, str | None], Wallet] (line 46) and cache keys are tuples like (wallet_id, conf.type, resolved_network) (line 138). However, in remove_wallet, the eviction comprehension calls cache_key.startswith(...), which is a string method and will raise AttributeError: 'tuple' object has no attribute 'startswith' at runtime whenever a wallet is removed after at least one wallet has been accessed (i.e., when the cache is non-empty).

Code:

# Line 46: keys are tuples
self._wallets: dict[tuple[str, WalletType, str | None], Wallet] = {}

# Line 138: cache_key assigned as tuple
cache_key = (wallet_id, conf.type, resolved_network)

# Line 99-102: bug — tuple does not have startswith
self._wallets = {
    cache_key: wallet
    for cache_key, wallet in self._wallets.items()
    if not cache_key.startswith(f"{wallet_id}:")  # AttributeError!
}

Recommendation:

self._wallets = {
    cache_key: wallet
    for cache_key, wallet in self._wallets.items()
    if cache_key[0] != wallet_id
}

[C-03] Privy app_secret Stored in Plaintext in wallets_config.json

Property Value
Severity Critical
Category Security / Credential Exposure
File packages/python/src/agent_wallet/core/config.py : Lines 42-48; packages/typescript/src/core/config.ts : Lines 44-48

Description: The PrivyWalletParams model stores app_secret directly in wallets_config.json as plaintext. The Privy App Secret is a high-value server-side credential that grants signing authority over all wallets in the application. Persisting it unencrypted in a config file means that any process with read access to ~/.agent-wallet/wallets_config.json (or the value of AGENT_WALLET_DIR) obtains full API signing access. While wallets_config.json is written with chmod 0o600, that protection is insufficient if the file is backed up, stored in a dotfile manager, or accidentally committed to version control.

The existing local_secure wallet type protects private keys via Keystore V3 encryption. No equivalent protection exists for Privy credentials.

Code:

class PrivyWalletParams(BaseModel):
    app_id: str
    app_secret: str   # stored in plaintext in wallets_config.json
    wallet_id: str

Recommendation:
In the short term, document clearly in the CLI help text and how-to-add-privy-wallet.md that app_secret is stored unencrypted and advise users to use environment variables (PRIVY_APP_SECRET) instead of persisting credentials in the config file. In the medium term, consider encrypting the app_secret field using the same Keystore V3 mechanism used for private keys, or offer an env-var fallback override for app_secret. At minimum, add a warning in the CLI when persisting a Privy wallet.


Major Findings

[M-01] No HTTP Request Timeout in Python PrivyClient

Property Value
Severity Major
Category Performance / Reliability
File packages/python/src/agent_wallet/core/clients/privy.py : Line 74

Description: urlopen(req) is called without a timeout argument. A hung or slow Privy API call will block the Python process indefinitely, causing the CLI or any calling agent to hang. The TypeScript implementation also has no explicit timeout, but Node.js's fetch can be cancelled externally via AbortController; Python's urlopen has no such ambient mechanism.

Code:

with urlopen(req) as response:   # no timeout — can block forever
    status = response.status
    payload = _read_json(response)

Recommendation:

with urlopen(req, timeout=30) as response:
    ...

Add a timeout parameter to PrivyClient.__init__ (defaulting to 30 seconds) and pass it to urlopen. Also add a timeout to the TypeScript fetch call via AbortController with signal.


[M-02] Python __init__.py Does Not Export Privy Classes

Property Value
Severity Major
Category API Contract / Maintainability
File packages/python/src/agent_wallet/__init__.py

Description: The TypeScript index.ts exports PrivyAdapter, PrivyClient, PrivyConfigResolver, and all Privy error classes. The Python __init__.py exports none of these. Users of the Python SDK who want to interact with Privy programmatically must know to import from internal submodule paths (agent_wallet.core.adapters.privy, etc.), which is fragile and breaks the principle of a stable public API surface. The Python package's __all__ list also does not include any Privy symbols, making the package surface appear incomplete relative to its TypeScript counterpart.

Code:

# __init__.py — Privy symbols completely absent from imports and __all__
from agent_wallet.core.errors import (
    DecryptionError,
    NetworkError,
    SigningError,
    # PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError — missing
    ...
)

Recommendation: Add the following to __init__.py:

  • Import and re-export PrivyAdapter, PrivyClient, PrivyConfigResolver
  • Import and re-export PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError
  • Add all new symbols to __all__

[M-03] Tron Typed Data Hashing Inconsistency Between Python and TypeScript

Property Value
Severity Major
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 134-136 vs packages/typescript/src/core/adapters/privy.ts : Lines 138-145

Description: The Python _tron_sign_typed_data uses eth_account.messages.encode_typed_data and manually constructs the digest as keccak(b"\x19" + version + header + body). The TypeScript tronSignTypedData uses viem.hashTypedData which computes the EIP-712 standard hash directly. These two approaches may produce different digests for identical inputs depending on how the EIP-712 domain separator and struct hash are handled. In particular, the Python path accesses signable.version, signable.header, and signable.body as raw bytes from SignableMessage, while viem.hashTypedData follows the full EIP-712 spec. A discrepancy here would cause Python and TypeScript Privy TRON typed-data signatures to not be verifiable by the same contract.

Code:

Python:

signable = encode_typed_data(full_message=payload)
digest = keccak(b"\x19" + signable.version + signable.header + signable.body)

TypeScript:

const hashHex = hashTypedData({
  domain, types: messageTypes, primaryType, message,
})

Recommendation: Add a cross-language integration test that generates the EIP-712 hash for a known typed data payload and asserts it is byte-identical across Python and TypeScript. If a discrepancy is found, align both to the hashTypedData (viem) behavior which is the canonical EIP-712 implementation.


[M-04] _tron_sign_bytes in Python Uses tronpy.hash_message While TypeScript Uses keccak256

Property Value
Severity Major
Category Correctness
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 119-122 vs packages/typescript/src/core/adapters/privy.ts : Lines 114-117

Description: For TRON sign_raw and sign_message, the Python implementation calls tronpy.keys.hash_message(data) while TypeScript calls keccak256(bytes). hash_message in tronpy typically prepends a Tron-specific message prefix before hashing (similar to Ethereum's \x19Ethereum Signed Message:\n prefix). Using different hash functions will produce different digests and therefore different signatures for the same input bytes. The Python TronSigner.sign_raw uses tronpy.sign_msg directly (which applies tronpy's own hashing), while the Python Privy adapter calls hash_message explicitly — these are consistent within Python, but the TypeScript counterpart uses plain keccak256, creating a cross-language divergence.

Code:

Python:

from tronpy.keys import hash_message
digest = hash_message(data)   # applies tron message prefix

TypeScript:

const hashHex = keccak256(bytes)   # raw keccak256 without prefix

Recommendation: Verify which behavior is expected by the Privy TRON API. If Privy's raw_sign operates on an un-prefixed hash, both implementations should use raw keccak256. If Privy or TRON nodes expect a message prefix, both must apply the same prefix. Align both implementations and add a cross-language test.


[M-05] Missing Blank Line in Python resolver.py (PEP 8 Violation at Module Level)

Property Value
Severity Major
Category Code Quality / Lint
File packages/python/src/agent_wallet/core/resolver.py : Lines 25-26

Description: The constant definition _DEFAULT_SECRETS_DIR and the function definition def resolve_wallet_provider are placed on consecutive lines without the required two blank lines separating top-level definitions. While not a runtime issue, this will cause the ruff linter to fail in CI, potentially blocking the PR.

Code:

_DEFAULT_SECRETS_DIR = os.path.join(Path.home(), ".agent-wallet")
def resolve_wallet_provider(    # missing two blank lines before function

Recommendation:

_DEFAULT_SECRETS_DIR = os.path.join(Path.home(), ".agent-wallet")

def resolve_wallet_provider(

Minor Findings

[N-01] Python PrivyClient sleep Parameter Uses callable (Not Callable[...])

Property Value
Severity Minor
Category Code Quality / Type Safety
File packages/python/src/agent_wallet/core/clients/privy.py : Line 22

Description: The type hint for the sleep parameter uses lowercase callable which is a built-in Python function, not a type. It should use collections.abc.Callable for a proper type annotation. This generates a ruff UP006 warning in modern Python.

Code:

sleep: callable | None = None,   # wrong — callable is a builtin, not a type

Recommendation:

from collections.abc import Callable
sleep: Callable[[float], None] | None = None,

[N-02] TS PrivyClient.rpc Response Type Cast Hides Potential Mismatches

Property Value
Severity Minor
Category Code Quality / Type Safety
File packages/typescript/src/core/clients/privy.ts : Lines 62-71

Description: rpc() calls this.request(...) (which returns Promise<unknown>) and casts the result to Promise<PrivyRpcResponse> via as Promise<PrivyRpcResponse>. This cast bypasses TypeScript type checking. If the Privy API changes its response shape, this will silently fail at runtime rather than producing a compile-time error. Similarly, rawSign uses the same pattern.

Code:

return this.request('POST', ...) as Promise<PrivyRpcResponse>

Recommendation: Return unknown from request and perform explicit runtime validation before casting, or use a Zod schema to parse the response. At minimum, type the request method's return as the expected type with a runtime shape check.


[N-03] normalizeTypedDataPayload Mutates the Input Object In-Place (TS)

Property Value
Severity Minor
Category Correctness / Side Effects
File packages/typescript/src/core/adapters/privy.ts : Lines 260-265

Description: The normalizeTypedDataPayload function mutates typed_data in-place by deleting primaryType and setting primary_type on the same object reference. Callers who hold a reference to the original payload will observe the mutation.

Code:

if ('primaryType' in typed && !('primary_type' in typed)) {
  typed.primary_type = typed.primaryType
  delete typed.primaryType   // mutates original object
}

Recommendation: Clone the object before mutating:

const mutableTyped = { ...typed }
mutableTyped.primary_type = mutableTyped.primaryType
delete mutableTyped.primaryType

[N-04] Python PrivyAdapter Creates a Redundant PrivyClient When One Is Provided

Property Value
Severity Minor
Category Code Quality
File packages/python/src/agent_wallet/core/adapters/privy.py : Lines 17-29

Description: The PrivyAdapter.__init__ accepts an optional client parameter for dependency injection (used in tests). However, wallet_builder.py (line 43-46) constructs the adapter with explicit app_id and app_secret parameters — meaning the adapter internally creates its own PrivyClient even though wallet_builder.py could also pass a pre-built client. This is a minor violation of DRY but more importantly it means the TypeScript and Python adapters have different constructor signatures: TypeScript takes a (config, client) pair while Python takes separate (app_id, app_secret, wallet_id, client?) args.

Recommendation: Either align the Python constructor to match TypeScript's (config, client) signature, or accept the divergence as intentional and document it. The current approach is functional but inconsistent.


[N-05] Backoff Strategy Uses Very Short Delays (200ms max 1s)

Property Value
Severity Minor
Category Performance / Reliability
File packages/typescript/src/core/clients/privy.ts : Line 143-145; packages/python/src/agent_wallet/core/clients/privy.py : Lines 133-134

Description: The rate-limit retry backoff maxes out at 1 second (Python: 1.0s, TypeScript: 1000ms). With only 2 retries by default, the client will exhaust retries within 1-2 seconds when hitting a Privy rate limit. Privy rate limits typically require longer backoff windows (e.g., 5-60 seconds). Under sustained rate limiting, the client will throw PrivyRateLimitError after a 1-second wait, leaving the caller with no practical recovery path.

Recommendation: Increase the maximum backoff to at least 5-10 seconds and consider adding jitter. Expose retries and maxBackoffMs as configurable parameters.


[N-06] resolveWalletAddresses Makes a Live Privy API Call to Resolve a Privy Wallet Address

Property Value
Severity Minor
Category Performance
File packages/typescript/src/core/address-resolution.ts : Lines 66-80; packages/python/src/agent_wallet/core/address_resolution.py : Lines 64-79

Description: The resolve-address CLI command calls resolvePrivyAddress which creates a new PrivyAdapter and issues a live GET /v1/wallets/{id} API call. This means every resolve-address invocation for a Privy wallet makes an external network call, which fails if offline, adds latency, and may consume API quota. There is no caching of the resolved address across CLI invocations.

Recommendation: Cache the resolved Privy wallet address in wallets_config.json after the first successful fetch, and display it immediately on subsequent resolve-address calls unless --refresh is passed. This is already done for the in-memory cachedAddress within a session but does not persist.


Suggestions

[S-01] Add Integration Test for Cross-Language TRON Signature Compatibility

Property Value
Severity Suggestion
Category Testing

Description: There is no automated test that verifies TRON signatures produced by the Python implementation are verifiable by the TypeScript implementation and vice-versa. Given the identified v-byte discrepancy and hash-function divergence (C-01, M-04), this gap means bugs can exist at the cross-language boundary without being caught by any existing test.

Recommendation: Add a test in CI that generates a TRON signature using both Python and TypeScript adapters for the same input and asserts the outputs match.


[S-02] Consider Env-Var Override for app_secret Without Persisting to Config

Property Value
Severity Suggestion
Category Security / UX

Description: Many teams prefer to inject secrets via environment variables rather than config files. Support PRIVY_APP_SECRET and PRIVY_APP_ID as env-var overrides when constructing Privy wallets, allowing users to omit app_secret from wallets_config.json entirely.


[S-03] .kiro/ Directory Should Be Excluded from Package Distribution

Property Value
Severity Suggestion
Category Maintainability
File .kiro/

Description: The .kiro/ directory (AI agent design rules, spec templates, steering principles) is developer tooling and should not be shipped in the published packages. It is already excluded by the Python pyproject.toml wheel configuration, but verify it is not included in npm files or sdist. It should also be added to .gitignore if it is intended to be team-local rather than shared.


[S-04] TypeScript PrivyClient Tests Pollute Global fetch Without Try/Finally

Property Value
Severity Suggestion
Category Testing
File packages/typescript/tests/privy-client.test.ts

Description: Each test in privy-client.test.ts restores globalThis.fetch = originalFetch after the assertion, but only in the happy path. If an assertion throws before the restore line, globalThis.fetch is permanently overridden for the rest of the test suite. Use afterEach hooks or vi.spyOn with vi.restoreAllMocks() instead.

Code:

const originalFetch = globalThis.fetch
// ... test ...
// If expect() throws, this line is never reached:
globalThis.fetch = originalFetch

Recommendation: Use vi.spyOn(globalThis, 'fetch').mockResolvedValue(...) and call vi.restoreAllMocks() in afterEach.


4. Positive Observations

  1. Comprehensive dual-language parity. The PR implements the same feature across Python and TypeScript with very high structural fidelity. The PrivyClient, PrivyAdapter, PrivyConfigResolver, and error types have matching designs, which makes cross-language maintenance practical.

  2. Excellent test coverage for new components. Each major new component (PrivyClient, PrivyAdapter, PrivyConfigResolver) has its own dedicated test file with fake/mock clients. The tests cover success paths, error handling, rate-limit retry, address caching, and TRON-specific signing flows.

  3. Clean dependency injection. Both PrivyAdapter and PrivyClient accept injectable client and sleep dependencies, making unit testing straightforward without any monkey-patching of module internals (Python tests patch only at the urlopen boundary; TypeScript tests use vi.fn() on globalThis.fetch).

  4. Appropriate error taxonomy. The PR introduces four distinct Privy error types (PrivyConfigError, PrivyRequestError, PrivyRateLimitError, PrivyAuthError), all extending the base WalletError. This allows callers to catch-and-retry on rate limits specifically, a thoughtful design for AI agents.

  5. Payload normalization is well-structured. The normalizeTransactionPayload function handles both camelCase and snake_case field names and auto-converts integer values to hex strings, providing a clean compatibility layer between viem-style transaction objects and the Privy API format.

  6. Keystore V3 wallets_config.json permissions. Config files continue to be written with chmod 0o600 on both platforms, and the directory is set to 0o700. This is a good baseline defense for local file protection.


5. Checklist Results

A. Correctness & Logic

Check Result Notes
Logic errors / incorrect conditions FAIL C-01: Python/TS TRON v-byte mismatch; C-02: startswith on tuple
Null/undefined handling PASS Both cachedAddress and cachedChainType are guarded
Error handling and propagation PASS Custom error hierarchy used consistently
Concurrency issues N/A No shared mutable state across requests
Resource leaks WARN Python HTTP connections use with urlopen() so are properly closed
API contract breaks WARN Privy adapter constructor differs between Python and TypeScript

B. Security

Check Result Notes
Injection vulnerabilities PASS No user input is interpolated into queries
Auth/Authz PASS Basic auth header correctly constructed
Input validation PASS PrivyConfigResolver validates required fields
Hardcoded secrets PASS No hardcoded credentials found
Data exposure in logs PASS No logging of secrets observed
Credential storage FAIL C-03: app_secret stored as plaintext in wallets_config.json
New dependency risks WARN No new dependencies added; relies on existing tronpy, eth-account

C. Performance

Check Result Notes
N+1 queries PASS Address caching prevents repeated Privy API calls per session
Unbounded operations WARN No HTTP timeout (M-01)
Unnecessary computation PASS
Missing caching WARN Privy address not persisted across CLI sessions (N-06)

D. Code Quality

Check Result Notes
Naming clarity PASS Consistent naming across languages
Function complexity PASS No function exceeds 50 lines meaningfully
Code duplication PASS Payload normalization is well-factored
Dead code PASS No unused imports or dead code observed
Separation of concerns PASS Client, adapter, config, and provider layers are clean

E. Testing

Check Result Notes
New logic without tests WARN Cross-language TRON signing consistency untested
Missing edge case tests WARN No test for remove_wallet cache eviction (masks C-02 bug)
Test pollution WARN TS client tests restore globalThis.fetch in happy path only (S-04)

F. Documentation & Maintainability

Check Result Notes
Public APIs without docs FAIL Python __init__.py does not export Privy public API (M-02)
Complex logic without comments PASS TRON v-recovery logic has inline comments
Migration scripts N/A Config format is backward-compatible (new privy type added)

6. Review Verdict

REQUEST CHANGES

Rationale:
Three critical issues must be resolved before merge:

  1. C-01 — The TRON v-byte value is 00/01 in Python and 1b/1c in TypeScript, producing incompatible signatures across the two SDK implementations. This will cause real-world integration failures for any cross-language usage of TRON Privy signing.

  2. C-02remove_wallet() in the Python ConfigWalletProvider calls startswith on a tuple cache key, which will raise AttributeError at runtime in production when any user removes a wallet that was previously accessed.

  3. C-03 — The Privy app_secret is persisted in plaintext in wallets_config.json. This is a significant security regression compared to the existing encrypted local_secure wallet type and should at minimum be documented with a strong security warning or mitigated before shipping.

The Major findings (M-01 through M-05) should also be addressed prior to merge, particularly M-02 (broken Python public API) and M-04/M-05 (linting failures and hashing inconsistency). The Minor and Suggestion items are recommended follow-ups.

@Will-Guan Will-Guan merged commit 28579d7 into main Apr 1, 2026
6 checks passed
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.

2 participants