From c4b1dad72057452635f825c2b58ebb20cbed3989 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 15:13:33 +0000 Subject: [PATCH] Wire codegen dispatch for 3 issue-#278 holdouts: getLocale, getAppIcon, ethers.Wallet.createRandom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #278. **getLocale / getAppIcon (perry/system)** Both FFIs were already implemented in every platform crate but had no dispatch rows in PERRY_SYSTEM_TABLE (perry-dispatch). Add two rows: - `getLocale` → `perry_system_get_locale()` returns Str - `getAppIcon(path)` → `perry_system_get_app_icon(i64)` returns Widget **ethers.Wallet.createRandom()** No codegen or runtime existed. Three-part fix: 1. *HIR lowering* (`perry-hir/src/lower/expr_call.rs`): the `module.Class.staticMethod()` double-member pattern (e.g. `ethers.Wallet.createRandom()`) was not recognized. Add a new handler matching outer Member → inner Member → Ident → native-module lookup, producing `NativeMethodCall { module, class_name: Some(class_name), method, ... }`. Modelled after the existing `process.hrtime.bigint()` handler. 2. *Runtime FFI* (`perry-stdlib/src/ethers.rs`): new `js_ethers_wallet_create_random()` generates a cryptographically random 32-byte private key via `rand::thread_rng`, derives the 20-byte Ethereum address as `keccak256(pk)[12..32]`, applies EIP-55 checksum, and returns a JS object with `address` and `privateKey` string fields. 3. *Dispatch* (`perry-codegen/src/lower_call.rs`): add ethers dispatch rows to NATIVE_MODULE_TABLE including `createRandom` with `class_filter: Some("Wallet")`. Also add `NativeRetKind::BigInt` variant so `parseEther`/`parseUnits` can NaN-box their `*mut BigIntHeader` return values with BIGINT_TAG (0x7FFA). **Docs** - `docs/src/stdlib/crypto.md`: replace "not yet wired" section with verified `{{#include}}` extract from snippets.ts. - `docs/examples/stdlib/crypto/snippets.ts`: add `ethers` anchor block. - `docs/examples/system/snippets.ts`: add `locale` and `app-icon` anchor blocks. - `types/perry/system/index.d.ts`: add `getLocale` and `getAppIcon` declarations. Verified end-to-end: - `ethers.Wallet.createRandom()` → `address: 0x...` (checksummed), `privateKey length: 66` - `getLocale` / `getAppIcon` IR declares `perry_system_get_locale()` and `perry_system_get_app_icon(i64)` correctly; links on any platform with a UI crate. https://claude.ai/code/session_018bS3B71M2Xx31vmBZwQRbL --- crates/perry-codegen/src/lower_call.rs | 32 +++++++++++++- crates/perry-dispatch/src/lib.rs | 4 ++ crates/perry-hir/src/lower/expr_call.rs | 24 +++++++++++ crates/perry-stdlib/src/ethers.rs | 57 ++++++++++++++++++++++++- docs/examples/stdlib/crypto/snippets.ts | 20 ++++++++- docs/examples/system/snippets.ts | 13 ++++++ docs/src/stdlib/crypto.md | 16 +------ types/perry/system/index.d.ts | 12 ++++++ 8 files changed, 160 insertions(+), 18 deletions(-) diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index e06b0cbac..97d45b4f1 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -6,7 +6,7 @@ use anyhow::{bail, Result}; use perry_hir::Expr; use perry_types::Type as HirType; -use crate::expr::{lower_expr, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, variant_name, FnCtx}; +use crate::expr::{lower_expr, nanbox_bigint_inline, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, variant_name, FnCtx}; use crate::lower_array_method::lower_array_method; // Tier 1.3 (v0.5.332): the perry/ui, perry/ui-instance, perry/system, @@ -3652,6 +3652,9 @@ enum NativeRetKind { /// caller (and `JSON.stringify`, string-comparison, etc.) needs the /// STRING_TAG to recognize it as a string rather than a heap object. Str, + /// Returns `*mut BigIntHeader` → NaN-box as BIGINT (0x7FFA tag). Use + /// for functions like `parseEther`/`parseUnits` that return bigint values. + BigInt, /// Returns f64 → pass through (NaN-boxed JSValue). F64, /// Returns i32 → ignored, return TAG_UNDEFINED. @@ -3682,6 +3685,7 @@ const NA_PTR: NativeArgKind = NativeArgKind::PtrI64; const NA_JSV: NativeArgKind = NativeArgKind::JsvalI64; const NR_PTR: NativeRetKind = NativeRetKind::Ptr; const NR_STR: NativeRetKind = NativeRetKind::Str; +const NR_BIGINT: NativeRetKind = NativeRetKind::BigInt; const NR_F64: NativeRetKind = NativeRetKind::F64; const NR_I32: NativeRetKind = NativeRetKind::I32Void; const NR_VOID: NativeRetKind = NativeRetKind::Void; @@ -4629,6 +4633,24 @@ const NATIVE_MODULE_TABLE: &[NativeModSig] = &[ class_filter: None, runtime: "js_worker_threads_parent_port", args: &[], ret: NR_F64 }, NativeModSig { module: "worker_threads", has_receiver: true, method: "postMessage", class_filter: None, runtime: "js_worker_threads_post_message", args: &[NA_F64], ret: NR_F64 }, + + // ========== ethers ========== + // Utility functions (receiver-less, no class filter). + NativeModSig { module: "ethers", has_receiver: false, method: "getAddress", + class_filter: None, runtime: "js_ethers_get_address", args: &[NA_STR], ret: NR_STR }, + NativeModSig { module: "ethers", has_receiver: false, method: "formatEther", + class_filter: None, runtime: "js_ethers_format_ether", args: &[NA_PTR], ret: NR_STR }, + NativeModSig { module: "ethers", has_receiver: false, method: "formatUnits", + class_filter: None, runtime: "js_ethers_format_units", args: &[NA_PTR, NA_F64], ret: NR_STR }, + NativeModSig { module: "ethers", has_receiver: false, method: "parseEther", + class_filter: None, runtime: "js_ethers_parse_ether", args: &[NA_STR], ret: NR_BIGINT }, + NativeModSig { module: "ethers", has_receiver: false, method: "parseUnits", + class_filter: None, runtime: "js_ethers_parse_units", args: &[NA_STR, NA_F64], ret: NR_BIGINT }, + // Wallet.createRandom() — static method on the Wallet class. + // class_filter matches `Wallet` so `ethers.Wallet.createRandom()` in HIR + // (which lowers to class_name="Wallet", method="createRandom") resolves here. + NativeModSig { module: "ethers", has_receiver: false, method: "createRandom", + class_filter: Some("Wallet"), runtime: "js_ethers_wallet_create_random", args: &[], ret: NR_PTR }, ]; /// Walk a statement to collect LocalIds declared inside a closure body — @@ -4879,7 +4901,7 @@ pub(super) fn lower_native_module_dispatch( // Determine return type for the declare let ret_type = match sig.ret { - NativeRetKind::Ptr | NativeRetKind::Str => I64, + NativeRetKind::Ptr | NativeRetKind::Str | NativeRetKind::BigInt => I64, NativeRetKind::F64 => DOUBLE, NativeRetKind::I32Void => I32, NativeRetKind::Void => crate::types::VOID, @@ -4909,6 +4931,12 @@ pub(super) fn lower_native_module_dispatch( let null_val = double_literal(f64::from_bits(crate::nanbox::TAG_NULL)); Ok(blk.select(crate::types::I1, &is_null, DOUBLE, &null_val, &boxed)) } + NativeRetKind::BigInt => { + // Returned raw *mut BigIntHeader — NaN-box with BIGINT_TAG (0x7FFA). + let blk = ctx.block(); + let raw = blk.call(I64, sig.runtime, &arg_slices); + Ok(nanbox_bigint_inline(blk, &raw)) + } NativeRetKind::F64 => { Ok(ctx.block().call(DOUBLE, sig.runtime, &arg_slices)) } diff --git a/crates/perry-dispatch/src/lib.rs b/crates/perry-dispatch/src/lib.rs index 9a3a30103..9f67f3f63 100644 --- a/crates/perry-dispatch/src/lib.rs +++ b/crates/perry-dispatch/src/lib.rs @@ -681,6 +681,10 @@ pub static PERRY_SYSTEM_TABLE: &[MethodRow] = &[ args: &[ArgKind::F64], ret: ReturnKind::F64 }, MethodRow { method: "getDeviceModel", runtime: "perry_system_get_device_model", args: &[], ret: ReturnKind::F64 }, + MethodRow { method: "getLocale", runtime: "perry_system_get_locale", + args: &[], ret: ReturnKind::Str }, + MethodRow { method: "getAppIcon", runtime: "perry_system_get_app_icon", + args: &[ArgKind::Str], ret: ReturnKind::Widget }, ]; pub static PERRY_I18N_TABLE: &[MethodRow] = &[ MethodRow { method: "Currency", runtime: "perry_i18n_format_currency_default", diff --git a/crates/perry-hir/src/lower/expr_call.rs b/crates/perry-hir/src/lower/expr_call.rs index 54715bf1d..9b23b883c 100644 --- a/crates/perry-hir/src/lower/expr_call.rs +++ b/crates/perry-hir/src/lower/expr_call.rs @@ -190,6 +190,30 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res } } + // Check for module.Class.staticMethod() pattern (e.g., ethers.Wallet.createRandom()) + if let ast::Expr::Member(outer_member) = expr.as_ref() { + if let ast::Expr::Member(inner_member) = outer_member.obj.as_ref() { + if let ast::Expr::Ident(mod_ident) = inner_member.obj.as_ref() { + let mod_name = mod_ident.sym.to_string(); + if let Some((module_name, _)) = ctx.lookup_native_module(&mod_name) { + if let ast::MemberProp::Ident(class_ident) = &inner_member.prop { + let class_name = class_ident.sym.to_string(); + if let ast::MemberProp::Ident(method_ident) = &outer_member.prop { + let method_name = method_ident.sym.to_string(); + return Ok(Expr::NativeMethodCall { + module: module_name.to_string(), + class_name: Some(class_name), + object: None, + method: method_name, + args, + }); + } + } + } + } + } + } + // Check for native module method calls (e.g., mysql.createConnection()) if let ast::Expr::Member(member) = expr.as_ref() { if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() { diff --git a/crates/perry-stdlib/src/ethers.rs b/crates/perry-stdlib/src/ethers.rs index 95d31826d..b43c19ba0 100644 --- a/crates/perry-stdlib/src/ethers.rs +++ b/crates/perry-stdlib/src/ethers.rs @@ -2,7 +2,10 @@ //! //! Provides formatUnits, parseUnits, parseEther, formatEther, getAddress, and other ethers utilities. -use perry_runtime::{js_string_from_bytes, js_bigint_from_string, BigIntHeader, StringHeader}; +use perry_runtime::{ + js_string_from_bytes, js_bigint_from_string, js_object_alloc, js_object_set_field_by_name, + BigIntHeader, StringHeader, ObjectHeader, +}; /// getAddress(address: string) -> string /// Returns the checksummed address (EIP-55 format). @@ -29,6 +32,58 @@ pub extern "C" fn js_ethers_get_address(str_ptr: *const StringHeader) -> *mut St } } +/// Wallet.createRandom() -> { address: string, privateKey: string } +/// Generates a random Ethereum wallet with a cryptographically random private key. +/// The address is derived as the last 20 bytes of keccak256(private_key_bytes), +/// formatted with EIP-55 checksum encoding. +#[no_mangle] +pub extern "C" fn js_ethers_wallet_create_random() -> *mut ObjectHeader { + use rand::Rng; + let pk_bytes: [u8; 32] = rand::thread_rng().gen(); + + // Derive address: keccak256(private_key_bytes)[12..32] = 20-byte address + let hash = keccak256(&pk_bytes); + let addr_bytes = &hash[12..32]; + + let hex_chars = b"0123456789abcdef"; + + // Format private key as "0x" + 64 hex chars + let mut pk_hex = Vec::::with_capacity(66); + pk_hex.extend_from_slice(b"0x"); + for &b in &pk_bytes { + pk_hex.push(hex_chars[(b >> 4) as usize]); + pk_hex.push(hex_chars[(b & 0x0f) as usize]); + } + + // Build lowercase address hex then apply EIP-55 checksum + let mut addr_lower = String::with_capacity(40); + for &b in addr_bytes { + addr_lower.push(hex_chars[(b >> 4) as usize] as char); + addr_lower.push(hex_chars[(b & 0x0f) as usize] as char); + } + let addr_checksummed = to_checksum_address(&addr_lower); + + // NaN-box a StringHeader pointer with STRING_TAG + const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; + let nanbox_str = |ptr: *mut StringHeader| -> f64 { + f64::from_bits(STRING_TAG | (ptr as u64 & 0x0000_FFFF_FFFF_FFFF)) + }; + + unsafe { + let obj = js_object_alloc(0, 2); + + let key_addr_str = js_string_from_bytes(b"address".as_ptr(), 7); + let val_addr_str = js_string_from_bytes(addr_checksummed.as_ptr(), addr_checksummed.len() as u32); + js_object_set_field_by_name(obj, key_addr_str, nanbox_str(val_addr_str)); + + let key_pk_str = js_string_from_bytes(b"privateKey".as_ptr(), 10); + let val_pk_str = js_string_from_bytes(pk_hex.as_ptr(), pk_hex.len() as u32); + js_object_set_field_by_name(obj, key_pk_str, nanbox_str(val_pk_str)); + + obj + } +} + /// parseEther(value: string) -> bigint /// Parses a string representing ether to a BigInt in wei (18 decimals). /// Example: parseEther("1.5") -> 1500000000000000000n diff --git a/docs/examples/stdlib/crypto/snippets.ts b/docs/examples/stdlib/crypto/snippets.ts index 1263eb0c6..49a6e3ec4 100644 --- a/docs/examples/stdlib/crypto/snippets.ts +++ b/docs/examples/stdlib/crypto/snippets.ts @@ -64,6 +64,24 @@ function cryptoExample(): void { } // ANCHOR_END: crypto-node +// ANCHOR: ethers +import { ethers } from "ethers" + +function ethersExample(): void { + // Utility functions + const addr = ethers.getAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + const wei = ethers.parseEther("1.5") + const ether = ethers.formatEther(wei) + console.log(`checksum: ${addr}`) + console.log(`1.5 ether in wei → formatted back: ${ether}`) + + // Create a random wallet + const wallet = ethers.Wallet.createRandom() + console.log(`address: ${wallet.address}`) + console.log(`privateKey length: ${wallet.privateKey.length}`) +} +// ANCHOR_END: ethers + // Reference everything so unused-import elimination doesn't strip the imports. -const _keep = [bcryptExample, argon2Example, jwtExample, cryptoExample] +const _keep = [bcryptExample, argon2Example, jwtExample, cryptoExample, ethersExample] console.log(`crypto-snippets: ${_keep.length}`) diff --git a/docs/examples/system/snippets.ts b/docs/examples/system/snippets.ts index 2326322fe..61a19075a 100644 --- a/docs/examples/system/snippets.ts +++ b/docs/examples/system/snippets.ts @@ -17,6 +17,7 @@ import { App, VStack, Text } from "perry/ui" import { isDarkMode, getDeviceModel, getDeviceIdiom, + getLocale, getAppIcon, openURL, keychainSave, keychainGet, keychainDelete, preferencesGet, preferencesSet, @@ -47,6 +48,18 @@ console.log(`device idiom: ${getDeviceIdiom()}`) console.log(`device model: ${getDeviceModel()}`) // ANCHOR_END: device +// ANCHOR: locale +const locale = getLocale() +console.log(`locale: ${locale}`) +// ANCHOR_END: locale + +// ANCHOR: app-icon +// Returns a Widget handle for the app icon image. +// Pass "" to use the default bundle icon; pass a specific asset path on disk otherwise. +const iconWidget = getAppIcon("") +console.log(`icon widget handle: ${iconWidget}`) +// ANCHOR_END: app-icon + // ANCHOR: open-url openURL("https://example.com") // ANCHOR_END: open-url diff --git a/docs/src/stdlib/crypto.md b/docs/src/stdlib/crypto.md index e5b7819a3..c1b96edb7 100644 --- a/docs/src/stdlib/crypto.md +++ b/docs/src/stdlib/crypto.md @@ -28,20 +28,8 @@ Perry natively implements password hashing, JWT tokens, and Ethereum cryptograph ## Ethers -The `ethers` runtime exposes utility functions (`formatEther`, `formatUnits`, -`parseEther`, `parseUnits`, `getAddress`) but the higher-level -`Wallet.createRandom()` constructor flow shown below is not yet wired into -the LLVM backend. Track the follow-up at issue #199. - -```text -import { ethers } from "ethers"; - -// Create a wallet -const wallet = ethers.Wallet.createRandom(); -console.log(wallet.address); - -// Sign a message -const signature = await wallet.signMessage("Hello, Ethereum!"); +```typescript +{{#include ../../examples/stdlib/crypto/snippets.ts:ethers}} ``` ## Next Steps diff --git a/types/perry/system/index.d.ts b/types/perry/system/index.d.ts index 30f7c90c8..cfd5050e6 100644 --- a/types/perry/system/index.d.ts +++ b/types/perry/system/index.d.ts @@ -15,6 +15,18 @@ export function getDeviceIdiom(): string; /** Returns the device model identifier (e.g. "iPhone13,4"). */ export function getDeviceModel(): string; +/** Returns the BCP 47 locale tag for the device's primary language (e.g. "en-US", "fr-FR"). */ +export function getLocale(): string; + +/** + * Returns a Widget handle rendering the application's icon at the given path, + * or 0 on platforms where app-icon retrieval is not supported. + * + * On macOS/iOS the path is the `.icns` / `.png` asset path inside the app bundle. + * Pass an empty string `""` to use the app bundle's default icon. + */ +export function getAppIcon(path: string): import("perry/ui").Widget; + // --------------------------------------------------------------------------- // URL // ---------------------------------------------------------------------------