diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ef2a2b..c1617c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: forge build forge test -vv + - name: Gas snapshot + run: forge snapshot + mobile: runs-on: ubuntu-latest defaults: diff --git a/.gitignore b/.gitignore index 3a84ccc..03388bc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ cache/ mobile/assets/config.json + mobile/.dart_tool/ + mobile/.flutter-plugins-dependencies \ No newline at end of file diff --git a/1.patch b/1.patch new file mode 100644 index 0000000..7f3e3da --- /dev/null +++ b/1.patch @@ -0,0 +1,730 @@ + (cd "$(git rev-parse --show-toplevel)" && git apply --3way <<'EOF' +diff --git a/mobile/assets/tokens.base.json b/mobile/assets/tokens.base.json +new file mode 100644 +index 0000000000000000000000000000000000000000..224a9f6a0dcddad7faa723bd81b58196772f5fd5 +--- /dev/null ++++ b/mobile/assets/tokens.base.json +@@ -0,0 +1,38 @@ ++{ ++ "chainIds": { ++ "base": 8453, ++ "baseSepolia": 84532 ++ }, ++ "tokens": [ ++ { ++ "symbol": "USDC", ++ "name": "USD Coin", ++ "decimals": 6, ++ "addresses": { ++ "8453": "0x833589FCD6EDB6E08f4f06f8f219cA9a4ba62F14", ++ "84532": "0x5Fd55A1d8DDD313dFa9e5cEdc7A9389C9D75C3b0" ++ }, ++ "features": { ++ "erc2612": false, ++ "permit2": true ++ } ++ }, ++ { ++ "symbol": "WETH", ++ "name": "Wrapped Ether", ++ "decimals": 18, ++ "addresses": { ++ "8453": "0x4200000000000000000000000000000000000006", ++ "84532": "0x4200000000000000000000000000000000000006" ++ }, ++ "features": { ++ "erc2612": false, ++ "permit2": false ++ } ++ } ++ ], ++ "permit2": { ++ "8453": "0x000000000022D473030F116dDEE9F6B43aC78BA3", ++ "84532": "0x000000000022D473030F116dDEE9F6B43aC78BA3" ++ } ++} +diff --git a/mobile/lib/models/token.dart b/mobile/lib/models/token.dart +new file mode 100644 +index 0000000000000000000000000000000000000000..1a6a5bc1aa45fb23cdc9cf9733d4a19c8cd18869 +--- /dev/null ++++ b/mobile/lib/models/token.dart +@@ -0,0 +1,40 @@ ++import 'dart:convert'; ++import 'package:flutter/services.dart' show rootBundle; ++ ++class ChainTokens { ++ final Map raw; ++ ChainTokens(this.raw); ++ ++ String? tokenAddress(String symbol, int chainId) { ++ final t = (raw['tokens'] as List).firstWhere( ++ (e) => e['symbol'] == symbol, ++ orElse: () => null, ++ ); ++ if (t == null) return null; ++ return (t['addresses'] as Map)[chainId.toString()] as String?; ++ } ++ ++ Map? token(String symbol) { ++ return (raw['tokens'] as List) ++ .cast?>() ++ .firstWhere((e) => e?['symbol'] == symbol, orElse: () => null); ++ } ++ ++ bool feature(String symbol, String name) { ++ final t = token(symbol); ++ if (t == null) return false; ++ return (t['features']?[name] ?? false) as bool; ++ } ++ ++ String? permit2Address(int chainId) { ++ return (raw['permit2'] as Map)[chainId.toString()] as String?; ++ } ++ ++ int chainIdBase() => (raw['chainIds']['base'] as num).toInt(); ++ int chainIdBaseSepolia() => (raw['chainIds']['baseSepolia'] as num).toInt(); ++ ++ static Future load() async { ++ final s = await rootBundle.loadString('assets/tokens.base.json'); ++ return ChainTokens(jsonDecode(s) as Map); ++ } ++} +diff --git a/mobile/lib/services/erc20.dart b/mobile/lib/services/erc20.dart +new file mode 100644 +index 0000000000000000000000000000000000000000..b2596d1514e328c0b18a9e74be9c6c353cda21e9 +--- /dev/null ++++ b/mobile/lib/services/erc20.dart +@@ -0,0 +1,72 @@ ++import 'dart:typed_data'; ++import 'package:web3dart/contracts.dart'; ++import 'package:web3dart/web3dart.dart' as w3; ++ ++class Erc20 { ++ static final _fnTransfer = ContractFunction( ++ 'transfer', ++ [ ++ FunctionParameter('to', AddressType()), ++ FunctionParameter('amount', UintType()) ++ ], ++ outputs: [FunctionParameter('', BoolType())], ++ ); ++ ++ static final _fnApprove = ContractFunction( ++ 'approve', ++ [ ++ FunctionParameter('spender', AddressType()), ++ FunctionParameter('amount', UintType()) ++ ], ++ outputs: [FunctionParameter('', BoolType())], ++ ); ++ ++ static final _fnPermit = ContractFunction( ++ 'permit', ++ [ ++ FunctionParameter('owner', AddressType()), ++ FunctionParameter('spender', AddressType()), ++ FunctionParameter('value', UintType()), ++ FunctionParameter('deadline', UintType()), ++ FunctionParameter('v', UintType(length: 8)), ++ FunctionParameter('r', FixedBytes(32)), ++ FunctionParameter('s', FixedBytes(32)), ++ ], ++ outputs: const [], ++ ); ++ ++ static Uint8List encodeTransfer(String to, BigInt amount) => ++ _fnTransfer.encodeCall([ ++ w3.EthereumAddress.fromHex(to), ++ amount, ++ ]); ++ ++ static Uint8List encodeApprove(String spender, BigInt amount) => ++ _fnApprove.encodeCall([ ++ w3.EthereumAddress.fromHex(spender), ++ amount, ++ ]); ++ ++ static Uint8List encodePermit({ ++ required String owner, ++ required String spender, ++ required BigInt value, ++ required BigInt deadline, ++ required int v, ++ required Uint8List r, ++ required Uint8List s, ++ }) => ++ _fnPermit.encodeCall([ ++ w3.EthereumAddress.fromHex(owner), ++ w3.EthereumAddress.fromHex(spender), ++ value, ++ deadline, ++ BigInt.from(v), ++ r, ++ s, ++ ]); ++} ++ ++class Permit2 { ++ // Placeholder for future Permit2 encoders ++} +diff --git a/mobile/lib/ui/send_token_sheet.dart b/mobile/lib/ui/send_token_sheet.dart +new file mode 100644 +index 0000000000000000000000000000000000000000..8f8229de8c49cf12eca70b8e7c94fc97d0f152c2 +--- /dev/null ++++ b/mobile/lib/ui/send_token_sheet.dart +@@ -0,0 +1,143 @@ ++import 'package:flutter/material.dart'; ++import '../models/token.dart'; ++import '../userop/userop_flow.dart'; ++import '../crypto/mnemonic.dart'; ++import '../state/settings.dart'; ++ ++class SendTokenSheet extends StatefulWidget { ++ final Map cfg; ++ final UserOpFlow flow; ++ final KeyMaterial keys; ++ final AppSettings settings; ++ const SendTokenSheet({ ++ super.key, ++ required this.cfg, ++ required this.flow, ++ required this.keys, ++ required this.settings, ++ }); ++ ++ @override ++ State createState() => _SendTokenSheetState(); ++} ++ ++class _SendTokenSheetState extends State { ++ ChainTokens? registry; ++ String selected = 'USDC'; ++ final toCtrl = TextEditingController(); ++ final amtCtrl = TextEditingController(); ++ bool usePermit = false; ++ bool usePermit2 = false; ++ bool sending = false; ++ ++ @override ++ void initState() { ++ super.initState(); ++ ChainTokens.load().then((r) => setState(() => registry = r)); ++ } ++ ++ BigInt _pow10(int d) => BigInt.from(10).pow(d); ++ ++ BigInt _parseAmount(String v, int decimals) { ++ final parts = v.split('.'); ++ final whole = parts[0].isEmpty ? BigInt.zero : BigInt.parse(parts[0]); ++ var frac = parts.length > 1 ? parts[1] : ''; ++ if (frac.length > decimals) { ++ frac = frac.substring(0, decimals); ++ } ++ final fracVal = ++ frac.isEmpty ? BigInt.zero : BigInt.parse(frac.padRight(decimals, '0')); ++ return whole * _pow10(decimals) + fracVal; ++ } ++ ++ Future _send() async { ++ if (registry == null) return; ++ final token = selected; ++ final tokenInfo = registry!.token(token)!; ++ final decimals = (tokenInfo['decimals'] as num).toInt(); ++ final amount = _parseAmount(amtCtrl.text.trim(), decimals); ++ final to = toCtrl.text.trim(); ++ setState(() => sending = true); ++ try { ++ await widget.flow.sendToken( ++ cfg: widget.cfg, ++ keys: widget.keys, ++ tokenSymbol: token, ++ recipient: to, ++ amountWeiLike: amount, ++ registry: registry!, ++ wantErc2612: usePermit, ++ wantPermit2: usePermit2, ++ settings: widget.settings, ++ log: (m) => debugPrint(m), ++ selectFees: (f) async => f, ++ ); ++ if (mounted) Navigator.of(context).pop(); ++ } catch (e) { ++ debugPrint('send error: $e'); ++ } finally { ++ if (mounted) setState(() => sending = false); ++ } ++ } ++ ++ @override ++ Widget build(BuildContext context) { ++ final tokens = registry?.raw['tokens'] as List? ?? []; ++ final permitDisabled = !(registry?.feature(selected, 'erc2612') ?? false); ++ final permit2Disabled = ++ registry?.permit2Address(widget.cfg['chainId'] as int) == null || ++ !(registry?.feature(selected, 'permit2') ?? false); ++ return Scaffold( ++ appBar: AppBar(title: const Text('Send Token')), ++ body: registry == null ++ ? const Center(child: CircularProgressIndicator()) ++ : Padding( ++ padding: const EdgeInsets.all(16), ++ child: Column( ++ children: [ ++ DropdownButton( ++ value: selected, ++ items: tokens ++ .map>((e) => DropdownMenuItem( ++ value: e['symbol'] as String, ++ child: Text(e['symbol'] as String), ++ )) ++ .toList(), ++ onChanged: (v) => setState(() => selected = v ?? selected), ++ ), ++ TextField( ++ controller: toCtrl, ++ decoration: ++ const InputDecoration(labelText: 'Recipient (0x...)'), ++ ), ++ TextField( ++ controller: amtCtrl, ++ decoration: const InputDecoration(labelText: 'Amount'), ++ keyboardType: ++ const TextInputType.numberWithOptions(decimal: true), ++ ), ++ SwitchListTile( ++ value: usePermit, ++ onChanged: permitDisabled ++ ? null ++ : (v) => setState(() => usePermit = v), ++ title: const Text('Use EIP-2612 permit'), ++ ), ++ SwitchListTile( ++ value: usePermit2, ++ onChanged: permit2Disabled ++ ? null ++ : (v) => setState(() => usePermit2 = v), ++ title: const Text('Use Permit2'), ++ ), ++ const SizedBox(height: 16), ++ ElevatedButton( ++ onPressed: sending ? null : _send, ++ child: const Text('Send'), ++ ), ++ ], ++ ), ++ ), ++ ); ++ } ++} +diff --git a/mobile/lib/userop/userop_flow.dart b/mobile/lib/userop/userop_flow.dart +index 7da5b7a8769f75e6e91f39cf6ba245ce26f8d7a6..4bcc86c2ca941b5a7d5f70dbb614e7bf72a8b270 100644 +--- a/mobile/lib/userop/userop_flow.dart ++++ b/mobile/lib/userop/userop_flow.dart +@@ -1,40 +1,42 @@ + import 'dart:typed_data'; + import 'package:web3dart/crypto.dart' as w3; + import 'package:web3dart/web3dart.dart'; + + import '../crypto/mnemonic.dart'; + import '../crypto/wots.dart'; + import '../services/bundler_client.dart'; + import '../services/rpc.dart'; + import '../services/storage.dart'; + import '../services/biometric.dart'; + import '../services/entrypoint.dart'; + import '../state/fees.dart'; + import '../state/settings.dart'; + import '../userop/userop.dart'; + import '../userop/userop_signer.dart'; ++import '../models/token.dart'; ++import '../services/erc20.dart'; + + class UserOpFlow { + final RpcClient rpc; + final BundlerClient bundler; + final PendingIndexStore store; + + UserOpFlow({required this.rpc, required this.bundler, required this.store}); + + Future sendEth({ + required Map cfg, + required KeyMaterial keys, + required EthereumAddress to, + required BigInt amountWei, + required AppSettings settings, + required void Function(String) log, + required Future Function(FeeState) selectFees, + }) async { + final chainId = cfg['chainId'] as int; + final wallet = EthereumAddress.fromHex(cfg['walletAddress']); + final entryPoint = EthereumAddress.fromHex(cfg['entryPoint']); + + // view function encodings + const fnNonce = ContractFunction('nonce', [], + outputs: [FunctionParameter('', UintType())]); + const fnCurrent = ContractFunction('currentPkCommit', [], +@@ -216,34 +218,312 @@ class UserOpFlow { + }; + if (pending != null && decision == 'rebuild') { + record['createdAt'] = pending['createdAt']; + } + await store.save(chainId, wallet.hex, record); + } + + final pendingStatus = pending == null ? 'absent' : 'present'; + log([ + 'pendingIndex: $pendingStatus', + 'nonce(): ${nonceOnChain.toString()}', + 'userOpHash(draft): ${userOpHashHex.substring(0, 10)}', + 'decision: $decision', + ].join('\n')); + + final uoh = await bundler.sendUserOperation(op.toJson(), entryPoint.hex); + record ??= await store.load(chainId, wallet.hex); + if (record != null) { + record['status'] = 'sent'; + record['lastAttemptAt'] = now; + await store.save(chainId, wallet.hex, record); + } + return uoh; + } + ++ Future sendToken({ ++ required Map cfg, ++ required KeyMaterial keys, ++ required String tokenSymbol, ++ required String recipient, ++ required BigInt amountWeiLike, ++ required ChainTokens registry, ++ required bool wantErc2612, ++ required bool wantPermit2, ++ required AppSettings settings, ++ required void Function(String) log, ++ required Future Function(FeeState) selectFees, ++ }) async { ++ final chainId = cfg['chainId'] as int; ++ final wallet = EthereumAddress.fromHex(cfg['walletAddress']); ++ final entryPoint = EthereumAddress.fromHex(cfg['entryPoint']); ++ ++ const fnNonce = ContractFunction('nonce', [], ++ outputs: [FunctionParameter('', UintType())]); ++ const fnCurrent = ContractFunction('currentPkCommit', [], ++ outputs: [FunctionParameter('', FixedBytes(32))]); ++ const fnNext = ContractFunction('nextPkCommit', [], ++ outputs: [FunctionParameter('', FixedBytes(32))]); ++ final dataNonce = fnNonce.encodeCall(const []); ++ final dataCur = fnCurrent.encodeCall(const []); ++ final dataNext = fnNext.encodeCall(const []); ++ ++ final nonceHex = await rpc.callViewHex( ++ wallet.hex, '0x${w3.bytesToHex(dataNonce, include0x: false)}'); ++ final curHex = await rpc.callViewHex( ++ wallet.hex, '0x${w3.bytesToHex(dataCur, include0x: false)}'); ++ final nextHex = await rpc.callViewHex( ++ wallet.hex, '0x${w3.bytesToHex(dataNext, include0x: false)}'); ++ ++ BigInt parseHexBigInt(String h) { ++ final s = h.startsWith('0x') ? h.substring(2) : h; ++ return s.isEmpty ? BigInt.zero : BigInt.parse(s, radix: 16); ++ } ++ ++ Uint8List parseHex32(String h) { ++ final b = w3.hexToBytes(h); ++ if (b.length == 32) return Uint8List.fromList(b); ++ if (b.length > 32) return Uint8List.fromList(b.sublist(b.length - 32)); ++ final out = Uint8List(32); ++ out.setRange(32 - b.length, 32, b); ++ return out; ++ } ++ ++ final nonceOnChain = parseHexBigInt(nonceHex); ++ final currentCommitOnChain = parseHex32(curHex); ++ final nextCommitOnChain = parseHex32(nextHex); ++ log('currentPkCommit: 0x${w3.bytesToHex(currentCommitOnChain, include0x: true)}'); ++ ++ final pending = await store.load(chainId, wallet.hex); ++ final callData = await buildTokenSendBatch( ++ chainId: chainId, ++ wallet: wallet.hex, ++ tokenSymbol: tokenSymbol, ++ recipient: recipient, ++ amountWeiLike: amountWeiLike, ++ wantErc2612: wantErc2612, ++ wantPermit2: wantPermit2, ++ registry: registry, ++ ); ++ ++ final op = UserOperation() ++ ..sender = wallet.hex ++ ..nonce = nonceOnChain ++ ..callData = callData; ++ ++ final gas = await bundler.estimateUserOpGas(op.toJson(), entryPoint.hex); ++ op.callGasLimit = BigInt.parse(gas['callGasLimit'].toString()); ++ op.verificationGasLimit = ++ BigInt.parse(gas['verificationGasLimit'].toString()); ++ op.preVerificationGas = BigInt.parse(gas['preVerificationGas'].toString()); ++ ++ final fh = await rpc.feeHistory(5, [50]); ++ final baseFees = (fh['baseFeePerGas'] as List) ++ .map((h) => BigInt.parse(h.toString().substring(2), radix: 16)) ++ .toList(); ++ final baseFee = baseFees.isNotEmpty ? baseFees.last : BigInt.zero; ++ ++ BigInt priority = BigInt.zero; ++ final rewards = fh['reward'] as List?; ++ if (rewards != null && rewards.isNotEmpty) { ++ final lastRewards = (rewards.last as List).cast(); ++ if (lastRewards.isNotEmpty) { ++ priority = BigInt.parse(lastRewards.first.substring(2), radix: 16); ++ } ++ } ++ if (priority == BigInt.zero) { ++ priority = await rpc.maxPriorityFeePerGas(); ++ } ++ final suggestedMaxFee = baseFee + priority; ++ ++ var fees = FeeState( ++ baseFee: baseFee, ++ prioritySuggestion: priority, ++ maxFeePerGas: suggestedMaxFee, ++ maxPriorityFeePerGas: priority, ++ preVerificationGas: op.preVerificationGas, ++ verificationGasLimit: op.verificationGasLimit, ++ callGasLimit: op.callGasLimit, ++ bundlerFeeWei: BigInt.zero, ++ ); ++ ++ final chosen = await selectFees(fees); ++ if (chosen == null) { ++ throw Exception('fee-canceled'); ++ } ++ fees = chosen; ++ ++ op.maxFeePerGas = fees.maxFeePerGas; ++ op.maxPriorityFeePerGas = fees.maxPriorityFeePerGas; ++ ++ final ep = EntryPointService(rpc, entryPoint); ++ final userOpHash = await ep.getUserOpHash(op); ++ final userOpHashHex = '0x${w3.bytesToHex(userOpHash, include0x: false)}'; ++ ++ final mustAuth = settings.requireBiometricForChain(chainId); ++ if (mustAuth) { ++ final bio = BiometricService(); ++ final can = await bio.canCheck(); ++ if (!can) { ++ log('Biometric requested but unavailable. Aborting.'); ++ throw Exception('biometric-unavailable'); ++ } ++ final ok = await bio.authenticate( ++ reason: 'Authenticate to sign & send this transaction'); ++ if (!ok) { ++ log('Authentication canceled/failed. Send aborted.'); ++ throw Exception('auth-failed'); ++ } ++ log('Authentication successful.'); ++ } ++ ++ final now = DateTime.now().toUtc().toIso8601String(); ++ String decision; ++ Map? record = pending; ++ ++ if (pending != null && ++ pending['userOpHash'] == userOpHashHex && ++ pending['index'] == nonceOnChain.toInt() && ++ (pending['entryPoint'] as String).toLowerCase() == ++ entryPoint.hex.toLowerCase() && ++ pending['networkChainId'] == chainId) { ++ decision = 'reuse'; ++ op.signature = ++ Uint8List.fromList(w3.hexToBytes(pending['signatureHybrid'])); ++ pending['status'] = 'sent'; ++ pending['lastAttemptAt'] = now; ++ await store.save(chainId, wallet.hex, pending); ++ } else { ++ if (pending != null && pending['index'] != nonceOnChain.toInt()) { ++ await store.clear(chainId, wallet.hex); ++ decision = 'stale-clear'; ++ } else if (pending != null) { ++ decision = 'rebuild'; ++ } else { ++ decision = 'fresh'; ++ } ++ ++ final creds = EthPrivateKey(Uint8List.fromList(keys.ecdsaPriv)); ++ final sigBytes = await creds.signToUint8List(userOpHash, chainId: null); ++ final eSig = w3.MsgSignature( ++ w3.bytesToInt(sigBytes.sublist(0, 32)), ++ w3.bytesToInt(sigBytes.sublist(32, 64)), ++ sigBytes[64], ++ ); ++ ++ final index = nonceOnChain.toInt(); ++ final seedI = hkdfIndex(Uint8List.fromList(keys.wotsMaster), index); ++ final (sk, pk) = Wots.keygen(seedI); ++ final wSig = Wots.sign(userOpHash, sk); ++ ++ final confirmCommit = nextCommitOnChain; ++ final nextNextSeed = ++ hkdfIndex(Uint8List.fromList(keys.wotsMaster), index + 2); ++ final (_, nextNextPk) = Wots.keygen(nextNextSeed); ++ final proposeCommit = Wots.commitPk(nextNextPk); ++ ++ op.signature = ++ packHybridSignature(eSig, wSig, pk, confirmCommit, proposeCommit); ++ ++ final pkBytes = pk.expand((e) => e).toList(); ++ record = { ++ 'version': 1, ++ 'wallet': wallet.hex, ++ 'entryPoint': entryPoint.hex, ++ 'networkChainId': chainId, ++ 'userOpHash': userOpHashHex, ++ 'nonce': nonceOnChain.toString(), ++ 'index': index, ++ 'signatureHybrid': '0x${w3.bytesToHex(op.signature, include0x: false)}', ++ 'confirmNextCommit': ++ '0x${w3.bytesToHex(confirmCommit, include0x: false)}', ++ 'proposeNextCommit': ++ '0x${w3.bytesToHex(proposeCommit, include0x: false)}', ++ 'wotsPk': '0x${w3.bytesToHex(pkBytes, include0x: false)}', ++ 'status': 'pending', ++ 'createdAt': now, ++ 'lastAttemptAt': now, ++ }; ++ if (pending != null && decision == 'rebuild') { ++ record['createdAt'] = pending['createdAt']; ++ } ++ await store.save(chainId, wallet.hex, record); ++ } ++ ++ final pendingStatus = pending == null ? 'absent' : 'present'; ++ log([ ++ 'pendingIndex: $pendingStatus', ++ 'nonce(): ${nonceOnChain.toString()}', ++ 'userOpHash(draft): ${userOpHashHex.substring(0, 10)}', ++ 'decision: $decision', ++ ].join('\n')); ++ ++ final uoh = await bundler.sendUserOperation(op.toJson(), entryPoint.hex); ++ record ??= await store.load(chainId, wallet.hex); ++ if (record != null) { ++ record['status'] = 'sent'; ++ record['lastAttemptAt'] = now; ++ await store.save(chainId, wallet.hex, record); ++ } ++ return uoh; ++ } ++ + Uint8List _encodeExecute(EthereumAddress to, BigInt value) { + const execute = ContractFunction('execute', [ + FunctionParameter('to', AddressType()), + FunctionParameter('value', UintType()), + FunctionParameter('data', DynamicBytes()), + ]); + return execute.encodeCall([to, value, Uint8List(0)]); + } + } ++ ++class Call { ++ final String to; ++ final BigInt value; ++ final Uint8List data; ++ Call(this.to, this.value, this.data); ++} ++ ++Uint8List encodeExecuteBatch(List calls) { ++ final executeBatch = ContractFunction('executeBatch', [ ++ FunctionParameter( ++ 'calls', ++ DynamicLengthArray( ++ type: TupleType([AddressType(), UintType(), DynamicBytes()]))) ++ ]); ++ final params = calls ++ .map((c) => [EthereumAddress.fromHex(c.to), c.value, c.data]) ++ .toList(); ++ return executeBatch.encodeCall([params]); ++} ++ ++Future buildTokenSendBatch({ ++ required int chainId, ++ required String wallet, ++ required String tokenSymbol, ++ required String recipient, ++ required BigInt amountWeiLike, ++ required bool wantErc2612, ++ required bool wantPermit2, ++ required ChainTokens registry, ++}) async { ++ final tokenAddr = registry.tokenAddress(tokenSymbol, chainId)!; ++ ++ final calls = []; ++ ++ if (wantErc2612 && registry.feature(tokenSymbol, 'erc2612')) { ++ // Path A not supported for smart contract owners; fallback to transfer ++ } ++ ++ if (wantPermit2 && registry.feature(tokenSymbol, 'permit2')) { ++ final permit2 = registry.permit2Address(chainId); ++ if (permit2 != null) { ++ // Path B not supported; fallback ++ } ++ } ++ ++ calls.add(Call( ++ tokenAddr, BigInt.zero, Erc20.encodeTransfer(recipient, amountWeiLike))); ++ ++ return encodeExecuteBatch(calls); ++} +diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml +index 55a53a0d8f09eca7ab64a0b4ddc2c1e76095aaf4..2350cff87891f3231bb8eb86b364169a152df564 100644 +--- a/mobile/pubspec.yaml ++++ b/mobile/pubspec.yaml +@@ -5,25 +5,26 @@ version: 0.1.0+1 + + environment: + sdk: ">=3.3.0 <4.0.0" + + dependencies: + flutter: + sdk: flutter + http: ^1.2.1 + web3dart: ^2.7.3 + bip39: ^1.0.6 + bip32: ^2.0.0 + crypto: ^3.0.3 + flutter_secure_storage: ^9.0.0 + local_auth: ^2.3.0 + collection: ^1.18.0 + + dev_dependencies: + flutter_test: + sdk: flutter + lints: ^3.0.0 + + flutter: + uses-material-design: true + assets: + - assets/config.example.json ++ - assets/tokens.base.json +diff --git a/mobile/test/token_model_test.dart b/mobile/test/token_model_test.dart +new file mode 100644 +index 0000000000000000000000000000000000000000..b4cc33b02f973b4ac80bec4491a081c162b56de3 +--- /dev/null ++++ b/mobile/test/token_model_test.dart +@@ -0,0 +1,12 @@ ++import 'package:flutter_test/flutter_test.dart'; ++import 'package:pqc_wallet/models/token.dart'; ++ ++void main() { ++ TestWidgetsFlutterBinding.ensureInitialized(); ++ test('loads token registry', () async { ++ final registry = await ChainTokens.load(); ++ expect(registry.chainIdBaseSepolia(), 84532); ++ final addr = registry.tokenAddress('USDC', 84532); ++ expect(addr, isNotNull); ++ }); ++} + +EOF +) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 5c07fe6..ea381ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,8 @@ # Documentation Project documentation for PQCWallet. + +## Nonce and WOTS Index + +The `nonce()` function of `PQCWallet` is the source of truth for the WOTS signature index. Each successful user operation +consumes one index and increments this nonce, so clients must read `nonce()` before deriving the next WOTS key. diff --git a/docs/dev/gas.md b/docs/dev/gas.md index 1cfce2c..e1035fe 100644 --- a/docs/dev/gas.md +++ b/docs/dev/gas.md @@ -1,11 +1,11 @@ # Gas Usage -Gas costs measured via `forge snapshot`: +Gas costs measured via `forge snapshot` (Phase-0, full WOTS on-chain): | Function | Gas | | --- | --- | -| `validateUserOp` | 2895059 | -| `execute` | 43624 | -| `executeBatch` | 47187 | +| `validateUserOp` | 2921463 | +| `execute` | 43735 | +| `executeBatch` | 47231 | Snapshot source: `smart-contracts/.gas-snapshot`. diff --git a/docs/dev/pqc.md b/docs/dev/pqc.md new file mode 100644 index 0000000..aaea2c6 --- /dev/null +++ b/docs/dev/pqc.md @@ -0,0 +1,10 @@ +# PQC + +## WOTS commit parity + +`WOTS.commitPK` (Solidity) and `Wots.commitPk` (Dart) both hash a full WOTS +public key by concatenating its 67 `bytes32` elements and applying `SHA-256`. + +A fixed public key vector where `pk[i] = bytes32(i)` produces the commitment +`0x765d90c3c681035923f5df7760cedea68ebd2d977fc22a3752839104c6b33176` in both +implementations, as enforced by unit tests. diff --git a/mobile/.dart_tool/extension_discovery/README.md b/mobile/.dart_tool/extension_discovery/README.md new file mode 100644 index 0000000..9dc6757 --- /dev/null +++ b/mobile/.dart_tool/extension_discovery/README.md @@ -0,0 +1,31 @@ +Extension Discovery Cache +========================= + +This folder is used by `package:extension_discovery` to cache lists of +packages that contains extensions for other packages. + +DO NOT USE THIS FOLDER +---------------------- + + * Do not read (or rely) the contents of this folder. + * Do write to this folder. + +If you're interested in the lists of extensions stored in this folder use the +API offered by package `extension_discovery` to get this information. + +If this package doesn't work for your use-case, then don't try to read the +contents of this folder. It may change, and will not remain stable. + +Use package `extension_discovery` +--------------------------------- + +If you want to access information from this folder. + +Feel free to delete this folder +------------------------------- + +Files in this folder act as a cache, and the cache is discarded if the files +are older than the modification time of `.dart_tool/package_config.json`. + +Hence, it should never be necessary to clear this cache manually, if you find a +need to do please file a bug. diff --git a/mobile/.dart_tool/extension_discovery/vs_code.json b/mobile/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..5505df2 --- /dev/null +++ b/mobile/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"pqc_wallet","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/mobile/.dart_tool/flutter_build/dart_plugin_registrant.dart b/mobile/.dart_tool/flutter_build/dart_plugin_registrant.dart new file mode 100644 index 0000000..252b571 --- /dev/null +++ b/mobile/.dart_tool/flutter_build/dart_plugin_registrant.dart @@ -0,0 +1,122 @@ +// +// Generated file. Do not edit. +// This file is generated from template in file `flutter_tools/lib/src/flutter_plugins.dart`. +// + +// @dart = 3.3 + +import 'dart:io'; // flutter_ignore: dart_io_import. +import 'package:local_auth_android/local_auth_android.dart'; +import 'package:path_provider_android/path_provider_android.dart'; +import 'package:local_auth_darwin/local_auth_darwin.dart'; +import 'package:path_provider_foundation/path_provider_foundation.dart'; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:local_auth_darwin/local_auth_darwin.dart'; +import 'package:path_provider_foundation/path_provider_foundation.dart'; +import 'package:flutter_secure_storage_windows/flutter_secure_storage_windows.dart'; +import 'package:local_auth_windows/local_auth_windows.dart'; +import 'package:path_provider_windows/path_provider_windows.dart'; + +@pragma('vm:entry-point') +class _PluginRegistrant { + + @pragma('vm:entry-point') + static void register() { + if (Platform.isAndroid) { + try { + LocalAuthAndroid.registerWith(); + } catch (err) { + print( + '`local_auth_android` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + try { + PathProviderAndroid.registerWith(); + } catch (err) { + print( + '`path_provider_android` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + } else if (Platform.isIOS) { + try { + LocalAuthDarwin.registerWith(); + } catch (err) { + print( + '`local_auth_darwin` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + try { + PathProviderFoundation.registerWith(); + } catch (err) { + print( + '`path_provider_foundation` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + } else if (Platform.isLinux) { + try { + PathProviderLinux.registerWith(); + } catch (err) { + print( + '`path_provider_linux` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + } else if (Platform.isMacOS) { + try { + LocalAuthDarwin.registerWith(); + } catch (err) { + print( + '`local_auth_darwin` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + try { + PathProviderFoundation.registerWith(); + } catch (err) { + print( + '`path_provider_foundation` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + } else if (Platform.isWindows) { + try { + FlutterSecureStorageWindows.registerWith(); + } catch (err) { + print( + '`flutter_secure_storage_windows` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + try { + LocalAuthWindows.registerWith(); + } catch (err) { + print( + '`local_auth_windows` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + try { + PathProviderWindows.registerWith(); + } catch (err) { + print( + '`path_provider_windows` threw an error: $err. ' + 'The app may not function as expected until you remove this plugin from pubspec.yaml' + ); + } + + } + } +} diff --git a/mobile/.dart_tool/package_config.json b/mobile/.dart_tool/package_config.json new file mode 100644 index 0000000..de1906a --- /dev/null +++ b/mobile/.dart_tool/package_config.json @@ -0,0 +1,496 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "async", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/async-2.13.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "bip32", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/bip32-2.0.0", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "bip39", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/bip39-1.0.6", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "boolean_selector", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "bs58check", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/bs58check-1.0.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "characters", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/characters-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "clock", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/clock-1.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "collection", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "convert", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/convert-3.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "crypto", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/crypto-3.0.6", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "eip1559", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/eip1559-0.6.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "eip55", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/eip55-1.0.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "fake_async", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/fake_async-1.3.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "ffi", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/ffi-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "file", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/file-7.0.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "fixnum", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/fixnum-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "flutter", + "rootUri": "file:///home/hooftly/.flutter/packages/flutter", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "flutter_plugin_android_lifecycle", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.30", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "flutter_secure_storage", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "flutter_secure_storage_linux", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "flutter_secure_storage_macos", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "flutter_secure_storage_platform_interface", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_platform_interface-1.1.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "flutter_secure_storage_web", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "flutter_secure_storage_windows", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "flutter_test", + "rootUri": "file:///home/hooftly/.flutter/packages/flutter_test", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "flutter_web_plugins", + "rootUri": "file:///home/hooftly/.flutter/packages/flutter_web_plugins", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "hex", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/hex-0.2.0", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "http", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/http-1.5.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "http_parser", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/http_parser-4.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "intl", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/intl-0.20.2", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "js", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/js-0.6.7", + "packageUri": "lib/", + "languageVersion": "2.19" + }, + { + "name": "json_rpc_2", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/json_rpc_2-3.0.3", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "leak_tracker", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/leak_tracker-11.0.1", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_flutter_testing", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_testing", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "lints", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/lints-3.0.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "local_auth", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/local_auth-2.3.0", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "local_auth_android", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/local_auth_android-1.0.52", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "local_auth_darwin", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/local_auth_darwin-1.6.0", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "local_auth_platform_interface", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/local_auth_platform_interface-1.0.10", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "local_auth_windows", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/local_auth_windows-1.0.11", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "matcher", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/matcher-0.12.17", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "material_color_utilities", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1", + "packageUri": "lib/", + "languageVersion": "2.17" + }, + { + "name": "meta", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/meta-1.16.0", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "path", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "path_provider", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path_provider-2.1.5", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "path_provider_android", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path_provider_android-2.2.18", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "path_provider_foundation", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "path_provider_linux", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1", + "packageUri": "lib/", + "languageVersion": "2.19" + }, + { + "name": "path_provider_platform_interface", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path_provider_platform_interface-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "path_provider_windows", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "platform", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/platform-3.1.6", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "plugin_platform_interface", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/plugin_platform_interface-2.1.8", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "pointycastle", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/pointycastle-3.9.1", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "sec", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/sec-1.1.0", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "shared_preferences", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences-2.5.3", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "shared_preferences_android", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.12", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "shared_preferences_foundation", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "shared_preferences_linux", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "shared_preferences_platform_interface", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_platform_interface-2.4.1", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "shared_preferences_web", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "shared_preferences_windows", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "sky_engine", + "rootUri": "file:///home/hooftly/.flutter/bin/cache/pkg/sky_engine", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "source_span", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/source_span-1.10.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "sprintf", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/sprintf-7.0.0", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "stack_trace", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "stream_transform", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/stream_transform-2.1.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "string_scanner", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test_api", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/test_api-0.7.6", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "typed_data", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "uuid", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/uuid-4.5.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "vector_math", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/vector_math-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "vm_service", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/vm_service-15.0.2", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "wallet", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/wallet-0.0.13", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "web", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/web-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web3dart", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/web3dart-2.7.3", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "win32", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/win32-5.14.0", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "xdg_directories", + "rootUri": "file:///home/hooftly/.pub-cache/hosted/pub.dev/xdg_directories-1.1.0", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "pqc_wallet", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.3" + } + ], + "generator": "pub", + "generatorVersion": "3.9.2", + "flutterRoot": "file:///home/hooftly/.flutter", + "flutterVersion": "3.35.3", + "pubCache": "file:///home/hooftly/.pub-cache" +} diff --git a/mobile/.dart_tool/package_graph.json b/mobile/.dart_tool/package_graph.json new file mode 100644 index 0000000..3ed53d0 --- /dev/null +++ b/mobile/.dart_tool/package_graph.json @@ -0,0 +1,699 @@ +{ + "roots": [ + "pqc_wallet" + ], + "packages": [ + { + "name": "pqc_wallet", + "version": "0.1.0+1", + "dependencies": [ + "bip32", + "bip39", + "collection", + "crypto", + "flutter", + "flutter_secure_storage", + "http", + "local_auth", + "shared_preferences", + "web3dart" + ], + "devDependencies": [ + "flutter_test", + "lints" + ] + }, + { + "name": "lints", + "version": "3.0.0", + "dependencies": [] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "local_auth", + "version": "2.3.0", + "dependencies": [ + "flutter", + "local_auth_android", + "local_auth_darwin", + "local_auth_platform_interface", + "local_auth_windows" + ] + }, + { + "name": "flutter_secure_storage", + "version": "9.2.4", + "dependencies": [ + "flutter", + "flutter_secure_storage_linux", + "flutter_secure_storage_macos", + "flutter_secure_storage_platform_interface", + "flutter_secure_storage_web", + "flutter_secure_storage_windows", + "meta" + ] + }, + { + "name": "crypto", + "version": "3.0.6", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "bip32", + "version": "2.0.0", + "dependencies": [ + "bs58check", + "hex", + "pointycastle" + ] + }, + { + "name": "bip39", + "version": "1.0.6", + "dependencies": [ + "crypto", + "hex", + "pointycastle" + ] + }, + { + "name": "web3dart", + "version": "2.7.3", + "dependencies": [ + "async", + "convert", + "eip1559", + "eip55", + "http", + "json_rpc_2", + "pointycastle", + "sec", + "stream_channel", + "stream_transform", + "typed_data", + "uuid", + "wallet" + ] + }, + { + "name": "http", + "version": "1.5.0", + "dependencies": [ + "async", + "http_parser", + "meta", + "web" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "meta", + "version": "1.16.0", + "dependencies": [] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.17", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "test_api", + "version": "0.7.6", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "local_auth_windows", + "version": "1.0.11", + "dependencies": [ + "flutter", + "local_auth_platform_interface" + ] + }, + { + "name": "local_auth_platform_interface", + "version": "1.0.10", + "dependencies": [ + "flutter", + "plugin_platform_interface" + ] + }, + { + "name": "local_auth_darwin", + "version": "1.6.0", + "dependencies": [ + "flutter", + "intl", + "local_auth_platform_interface" + ] + }, + { + "name": "local_auth_android", + "version": "1.0.52", + "dependencies": [ + "flutter", + "flutter_plugin_android_lifecycle", + "intl", + "local_auth_platform_interface" + ] + }, + { + "name": "flutter_secure_storage_windows", + "version": "3.1.2", + "dependencies": [ + "ffi", + "flutter", + "flutter_secure_storage_platform_interface", + "path", + "path_provider", + "win32" + ] + }, + { + "name": "flutter_secure_storage_web", + "version": "1.2.1", + "dependencies": [ + "flutter", + "flutter_secure_storage_platform_interface", + "flutter_web_plugins", + "js" + ] + }, + { + "name": "flutter_secure_storage_platform_interface", + "version": "1.1.2", + "dependencies": [ + "flutter", + "plugin_platform_interface" + ] + }, + { + "name": "flutter_secure_storage_macos", + "version": "3.1.3", + "dependencies": [ + "flutter", + "flutter_secure_storage_platform_interface" + ] + }, + { + "name": "flutter_secure_storage_linux", + "version": "1.2.3", + "dependencies": [ + "flutter", + "flutter_secure_storage_platform_interface" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "bs58check", + "version": "1.0.2", + "dependencies": [ + "crypto", + "hex" + ] + }, + { + "name": "hex", + "version": "0.2.0", + "dependencies": [] + }, + { + "name": "pointycastle", + "version": "3.9.1", + "dependencies": [ + "collection", + "convert", + "js" + ] + }, + { + "name": "wallet", + "version": "0.0.13", + "dependencies": [ + "convert", + "eip55", + "pointycastle", + "sec" + ] + }, + { + "name": "async", + "version": "2.13.0", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "convert", + "version": "3.1.2", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "eip1559", + "version": "0.6.2", + "dependencies": [ + "http" + ] + }, + { + "name": "eip55", + "version": "1.0.2", + "dependencies": [ + "pointycastle" + ] + }, + { + "name": "stream_transform", + "version": "2.1.1", + "dependencies": [] + }, + { + "name": "json_rpc_2", + "version": "3.0.3", + "dependencies": [ + "stack_trace", + "stream_channel" + ] + }, + { + "name": "uuid", + "version": "4.5.1", + "dependencies": [ + "crypto", + "fixnum", + "meta", + "sprintf" + ] + }, + { + "name": "sec", + "version": "1.1.0", + "dependencies": [ + "pointycastle" + ] + }, + { + "name": "web", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.11.1", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.0", + "dependencies": [] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.1", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.1", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "plugin_platform_interface", + "version": "2.1.8", + "dependencies": [ + "meta" + ] + }, + { + "name": "intl", + "version": "0.20.2", + "dependencies": [ + "clock", + "meta", + "path" + ] + }, + { + "name": "flutter_plugin_android_lifecycle", + "version": "2.0.30", + "dependencies": [ + "flutter" + ] + }, + { + "name": "win32", + "version": "5.14.0", + "dependencies": [ + "ffi" + ] + }, + { + "name": "path_provider", + "version": "2.1.5", + "dependencies": [ + "flutter", + "path_provider_android", + "path_provider_foundation", + "path_provider_linux", + "path_provider_platform_interface", + "path_provider_windows" + ] + }, + { + "name": "ffi", + "version": "2.1.4", + "dependencies": [] + }, + { + "name": "js", + "version": "0.6.7", + "dependencies": [ + "meta" + ] + }, + { + "name": "flutter_web_plugins", + "version": "0.0.0", + "dependencies": [ + "flutter" + ] + }, + { + "name": "fixnum", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "sprintf", + "version": "7.0.0", + "dependencies": [] + }, + { + "name": "vm_service", + "version": "15.0.2", + "dependencies": [] + }, + { + "name": "path_provider_windows", + "version": "2.3.0", + "dependencies": [ + "ffi", + "flutter", + "path", + "path_provider_platform_interface" + ] + }, + { + "name": "path_provider_platform_interface", + "version": "2.1.2", + "dependencies": [ + "flutter", + "platform", + "plugin_platform_interface" + ] + }, + { + "name": "path_provider_linux", + "version": "2.2.1", + "dependencies": [ + "ffi", + "flutter", + "path", + "path_provider_platform_interface", + "xdg_directories" + ] + }, + { + "name": "path_provider_foundation", + "version": "2.4.2", + "dependencies": [ + "flutter", + "path_provider_platform_interface" + ] + }, + { + "name": "path_provider_android", + "version": "2.2.18", + "dependencies": [ + "flutter", + "path_provider_platform_interface" + ] + }, + { + "name": "platform", + "version": "3.1.6", + "dependencies": [] + }, + { + "name": "xdg_directories", + "version": "1.1.0", + "dependencies": [ + "meta", + "path" + ] + }, + { + "name": "shared_preferences", + "version": "2.5.3", + "dependencies": [ + "flutter", + "shared_preferences_android", + "shared_preferences_foundation", + "shared_preferences_linux", + "shared_preferences_platform_interface", + "shared_preferences_web", + "shared_preferences_windows" + ] + }, + { + "name": "shared_preferences_windows", + "version": "2.4.1", + "dependencies": [ + "file", + "flutter", + "path", + "path_provider_platform_interface", + "path_provider_windows", + "shared_preferences_platform_interface" + ] + }, + { + "name": "shared_preferences_platform_interface", + "version": "2.4.1", + "dependencies": [ + "flutter", + "plugin_platform_interface" + ] + }, + { + "name": "shared_preferences_linux", + "version": "2.4.1", + "dependencies": [ + "file", + "flutter", + "path", + "path_provider_linux", + "path_provider_platform_interface", + "shared_preferences_platform_interface" + ] + }, + { + "name": "shared_preferences_web", + "version": "2.4.3", + "dependencies": [ + "flutter", + "flutter_web_plugins", + "shared_preferences_platform_interface", + "web" + ] + }, + { + "name": "shared_preferences_foundation", + "version": "2.5.4", + "dependencies": [ + "flutter", + "shared_preferences_platform_interface" + ] + }, + { + "name": "file", + "version": "7.0.1", + "dependencies": [ + "meta", + "path" + ] + }, + { + "name": "shared_preferences_android", + "version": "2.4.12", + "dependencies": [ + "flutter", + "shared_preferences_platform_interface" + ] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/mobile/.dart_tool/version b/mobile/.dart_tool/version new file mode 100644 index 0000000..398f1f6 --- /dev/null +++ b/mobile/.dart_tool/version @@ -0,0 +1 @@ +3.35.3 \ No newline at end of file diff --git a/mobile/.flutter-plugins-dependencies b/mobile/.flutter-plugins-dependencies new file mode 100644 index 0000000..19a77ed --- /dev/null +++ b/mobile/.flutter-plugins-dependencies @@ -0,0 +1 @@ +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_secure_storage","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"local_auth_darwin","path":"/home/hooftly/.pub-cache/hosted/pub.dev/local_auth_darwin-1.6.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/hooftly/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_plugin_android_lifecycle","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.30/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"local_auth_android","path":"/home/hooftly/.pub-cache/hosted/pub.dev/local_auth_android-1.0.52/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/hooftly/.pub-cache/hosted/pub.dev/path_provider_android-2.2.18/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.12/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"flutter_secure_storage_macos","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"local_auth_darwin","path":"/home/hooftly/.pub-cache/hosted/pub.dev/local_auth_darwin-1.6.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/hooftly/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"flutter_secure_storage_linux","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/hooftly/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"flutter_secure_storage_windows","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"local_auth_windows","path":"/home/hooftly/.pub-cache/hosted/pub.dev/local_auth_windows-1.0.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/hooftly/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"/home/hooftly/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/hooftly/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"local_auth","dependencies":["local_auth_android","local_auth_darwin","local_auth_windows"]},{"name":"local_auth_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"local_auth_darwin","dependencies":[]},{"name":"local_auth_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]}],"date_created":"2025-09-08 21:49:46.282715","version":"3.35.3","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9ffdfc2 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 0000000..b1d630d --- /dev/null +++ b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,44 @@ +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.localauth.LocalAuthPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin local_auth_android, io.flutter.plugins.localauth.LocalAuthPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); + } + } +} diff --git a/mobile/android/local.properties b/mobile/android/local.properties new file mode 100644 index 0000000..d3bded8 --- /dev/null +++ b/mobile/android/local.properties @@ -0,0 +1,2 @@ +flutter.sdk=/home/hooftly/.flutter +sdk.dir=/home/hooftly/Android \ No newline at end of file diff --git a/mobile/assets/config.example.json b/mobile/assets/config.example.json index a0e6b59..bab3102 100644 --- a/mobile/assets/config.example.json +++ b/mobile/assets/config.example.json @@ -1,5 +1,11 @@ { - "rpcUrl": "https://base-mainnet.g.alchemy.com/v2/YOUR_KEY", - "bundlerUrl": "https://base-mainnet.g.alchemy.com/aa/YOUR_KEY", - "entryPointAddr": "0x0000000000000000000000000000000000000000" + "chainId": 84532, + "rpcUrl": "https://base-sepolia.g.alchemy.com/v2/KEY", + "bundlerUrl": "https://base-sepolia.g.alchemy.com/aa/KEY", + "entryPoint": "0x5ff137D4b0FdCD49dCa30C7Cf57F612B49c9Fd7F", + "walletAddress": "0xWALLET", + "aggregator": "0x0000000000000000000000000000000000000000", + "proverRegistry": "0x0000000000000000000000000000000000000000", + "forceOnChainVerify": true } + diff --git a/mobile/assets/tokens.base.json b/mobile/assets/tokens.base.json new file mode 100644 index 0000000..224a9f6 --- /dev/null +++ b/mobile/assets/tokens.base.json @@ -0,0 +1,38 @@ +{ + "chainIds": { + "base": 8453, + "baseSepolia": 84532 + }, + "tokens": [ + { + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "addresses": { + "8453": "0x833589FCD6EDB6E08f4f06f8f219cA9a4ba62F14", + "84532": "0x5Fd55A1d8DDD313dFa9e5cEdc7A9389C9D75C3b0" + }, + "features": { + "erc2612": false, + "permit2": true + } + }, + { + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "addresses": { + "8453": "0x4200000000000000000000000000000000000006", + "84532": "0x4200000000000000000000000000000000000006" + }, + "features": { + "erc2612": false, + "permit2": false + } + } + ], + "permit2": { + "8453": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "84532": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + } +} diff --git a/mobile/build/native_assets/linux/native_assets.json b/mobile/build/native_assets/linux/native_assets.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/mobile/build/native_assets/linux/native_assets.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/mobile/build/test_cache/build/b9dbe592fc2ae558329e0a126bb30b5a.cache.dill.track.dill b/mobile/build/test_cache/build/b9dbe592fc2ae558329e0a126bb30b5a.cache.dill.track.dill new file mode 100644 index 0000000..b6fc3c2 Binary files /dev/null and b/mobile/build/test_cache/build/b9dbe592fc2ae558329e0a126bb30b5a.cache.dill.track.dill differ diff --git a/mobile/build/unit_test_assets/AssetManifest.bin b/mobile/build/unit_test_assets/AssetManifest.bin new file mode 100644 index 0000000..ca5d280 --- /dev/null +++ b/mobile/build/unit_test_assets/AssetManifest.bin @@ -0,0 +1 @@ + assets/config.example.json  assetassets/config.example.json \ No newline at end of file diff --git a/mobile/build/unit_test_assets/AssetManifest.json b/mobile/build/unit_test_assets/AssetManifest.json new file mode 100644 index 0000000..353f3c3 --- /dev/null +++ b/mobile/build/unit_test_assets/AssetManifest.json @@ -0,0 +1 @@ +{"assets/config.example.json":["assets/config.example.json"]} \ No newline at end of file diff --git a/mobile/build/unit_test_assets/FontManifest.json b/mobile/build/unit_test_assets/FontManifest.json new file mode 100644 index 0000000..3abf18c --- /dev/null +++ b/mobile/build/unit_test_assets/FontManifest.json @@ -0,0 +1 @@ +[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]}] \ No newline at end of file diff --git a/mobile/build/unit_test_assets/NOTICES.Z b/mobile/build/unit_test_assets/NOTICES.Z new file mode 100644 index 0000000..08d4158 Binary files /dev/null and b/mobile/build/unit_test_assets/NOTICES.Z differ diff --git a/mobile/build/unit_test_assets/NativeAssetsManifest.json b/mobile/build/unit_test_assets/NativeAssetsManifest.json new file mode 100644 index 0000000..523bfc7 --- /dev/null +++ b/mobile/build/unit_test_assets/NativeAssetsManifest.json @@ -0,0 +1 @@ +{"format-version":[1,0,0],"native-assets":{}} \ No newline at end of file diff --git a/mobile/build/unit_test_assets/assets/config.example.json b/mobile/build/unit_test_assets/assets/config.example.json new file mode 100644 index 0000000..bab3102 --- /dev/null +++ b/mobile/build/unit_test_assets/assets/config.example.json @@ -0,0 +1,11 @@ +{ + "chainId": 84532, + "rpcUrl": "https://base-sepolia.g.alchemy.com/v2/KEY", + "bundlerUrl": "https://base-sepolia.g.alchemy.com/aa/KEY", + "entryPoint": "0x5ff137D4b0FdCD49dCa30C7Cf57F612B49c9Fd7F", + "walletAddress": "0xWALLET", + "aggregator": "0x0000000000000000000000000000000000000000", + "proverRegistry": "0x0000000000000000000000000000000000000000", + "forceOnChainVerify": true +} + diff --git a/mobile/build/unit_test_assets/fonts/MaterialIcons-Regular.otf b/mobile/build/unit_test_assets/fonts/MaterialIcons-Regular.otf new file mode 100644 index 0000000..8c99266 Binary files /dev/null and b/mobile/build/unit_test_assets/fonts/MaterialIcons-Regular.otf differ diff --git a/mobile/build/unit_test_assets/shaders/ink_sparkle.frag b/mobile/build/unit_test_assets/shaders/ink_sparkle.frag new file mode 100644 index 0000000..85fc357 Binary files /dev/null and b/mobile/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/mobile/ios/Flutter/Generated.xcconfig b/mobile/ios/Flutter/Generated.xcconfig new file mode 100644 index 0000000..d4a0e67 --- /dev/null +++ b/mobile/ios/Flutter/Generated.xcconfig @@ -0,0 +1,14 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/home/hooftly/.flutter +FLUTTER_APPLICATION_PATH=/home/hooftly/Projects/PQCWallet/mobile +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib/main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=0.1.0 +FLUTTER_BUILD_NUMBER=1 +EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 +EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py b/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 0000000..a88caf9 --- /dev/null +++ b/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/mobile/ios/Flutter/ephemeral/flutter_lldbinit b/mobile/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 0000000..e3ba6fb --- /dev/null +++ b/mobile/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/mobile/ios/Flutter/flutter_export_environment.sh b/mobile/ios/Flutter/flutter_export_environment.sh new file mode 100755 index 0000000..7147af0 --- /dev/null +++ b/mobile/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/home/hooftly/.flutter" +export "FLUTTER_APPLICATION_PATH=/home/hooftly/Projects/PQCWallet/mobile" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=0.1.0" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.h b/mobile/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 0000000..7a89092 --- /dev/null +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 0000000..129efcf --- /dev/null +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,42 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import local_auth_darwin; +#endif + +#if __has_include() +#import +#else +@import path_provider_foundation; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; + [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; +} + +@end diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..47ddbdd --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,8 @@ + + + + + NSFaceIDUsageDescription + Authenticate to approve transactions. + + diff --git a/mobile/lib/crypto/wots.dart b/mobile/lib/crypto/wots.dart index e399e2e..40e86d6 100644 --- a/mobile/lib/crypto/wots.dart +++ b/mobile/lib/crypto/wots.dart @@ -4,7 +4,7 @@ import 'package:crypto/crypto.dart'; class Wots { static const int w = 16; static const int L1 = 64; // 256 bits / log2(16) - static const int L2 = 3; // ceil(log_16(960)) = 3 + static const int L2 = 3; // ceil(log_16(960)) = 3 static const int L = L1 + L2; // 67 static const int n = 32; // bytes per element @@ -14,8 +14,8 @@ class Wots { final out = List.filled(64, 0); for (int i = 0; i < 32; i++) { final b = h[i]; - out[2*i] = (b >> 4) & 0x0f; - out[2*i+1] = b & 0x0f; + out[2 * i] = (b >> 4) & 0x0f; + out[2 * i + 1] = b & 0x0f; } return out; } @@ -23,14 +23,16 @@ class Wots { static List _digitsWithChecksum(Uint8List msgHash) { final d = List.filled(L, 0); final nibbles = _toNibbles(msgHash); - for (int i = 0; i < L1; i++) { d[i] = nibbles[i]; } + for (int i = 0; i < L1; i++) { + d[i] = nibbles[i]; + } int csum = 0; for (int i = 0; i < L1; i++) csum += (w - 1) - d[i]; - d[L1] = (csum >> 8) & 0x0f; - d[L1+1] = (csum >> 4) & 0x0f; - d[L1+2] = csum & 0x0f; + d[L1] = (csum >> 8) & 0x0f; + d[L1 + 1] = (csum >> 4) & 0x0f; + d[L1 + 2] = csum & 0x0f; return d; - } + } /// Deterministic keygen from 32-byte seed. static (List sk, List pk) keygen(Uint8List seed) { @@ -39,7 +41,9 @@ class Wots { for (int i = 0; i < L; i++) { final s = sha256.convert([...seed, i, 0, 0, 0]).bytes; // simple expand Uint8List v = Uint8List.fromList(s); - for (int j = 0; j < w - 1; j++) { v = Uint8List.fromList(_F(v)); } + for (int j = 0; j < w - 1; j++) { + v = Uint8List.fromList(_F(v)); + } sk.add(Uint8List.fromList(s)); pk.add(v); } @@ -51,7 +55,9 @@ class Wots { final sig = []; for (int i = 0; i < L; i++) { Uint8List v = sk[i]; - for (int j = 0; j < d[i]; j++) { v = Uint8List.fromList(_F(v)); } + for (int j = 0; j < d[i]; j++) { + v = Uint8List.fromList(_F(v)); + } sig.add(v); } return sig; @@ -59,7 +65,9 @@ class Wots { static Uint8List commitPk(List pk) { final concat = []; - for (final p in pk) { concat.addAll(p); } + for (final p in pk) { + concat.addAll(p); + } return Uint8List.fromList(sha256.convert(concat).bytes); } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c849f4c..9d0bd9d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,18 +1,22 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:web3dart/web3dart.dart'; -import 'package:web3dart/crypto.dart' as w3; import 'theme/theme.dart'; import 'services/rpc.dart'; import 'services/bundler_client.dart'; import 'crypto/mnemonic.dart'; -import 'crypto/wots.dart'; -import 'userop/userop.dart'; -import 'userop/userop_signer.dart'; +import 'services/storage.dart'; +import 'userop/userop_flow.dart'; +import 'state/settings.dart'; +import 'ui/settings_screen.dart'; +import 'ui/send_sheet.dart'; +import 'models/activity.dart'; +import 'services/activity_store.dart'; +import 'services/activity_poller.dart'; +import 'ui/activity_feed.dart'; void main() => runApp(const PQCApp()); @@ -28,6 +32,8 @@ class _PQCAppState extends State { final storage = const FlutterSecureStorage(); KeyMaterial? _keys; String _status = 'Ready'; + AppSettings _settings = const AppSettings(); + final SettingsStore _settingsStore = SettingsStore(); @override void initState() { @@ -46,7 +52,21 @@ class _PQCAppState extends State { final km = deriveFromMnemonic(existing); if (existing == null) await storage.write(key: 'mnemonic', value: km.mnemonic); - setState(() => _keys = km); + final s = await _settingsStore.load(); + setState(() { + _keys = km; + _settings = s; + }); + } + + Future _openSettings() async { + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => SettingsScreen( + settings: _settings, + store: _settingsStore, + ))); + final s = await _settingsStore.load(); + setState(() => _settings = s); } @override @@ -54,12 +74,19 @@ class _PQCAppState extends State { return MaterialApp( theme: _theme, home: Scaffold( - appBar: AppBar(title: const Text('EqualFi PQC Wallet (MVP)')), + appBar: AppBar( + title: const Text('EqualFi PQC Wallet (MVP)'), + actions: [ + IconButton( + onPressed: _openSettings, icon: const Icon(Icons.settings)) + ], + ), body: _cfg == null || _keys == null ? const Center(child: CircularProgressIndicator()) : _Body( cfg: _cfg!, keys: _keys!, + settings: _settings, setStatus: (s) => setState(() => _status = s)), bottomNavigationBar: Container( padding: const EdgeInsets.all(12), @@ -73,8 +100,14 @@ class _PQCAppState extends State { class _Body extends StatefulWidget { final Map cfg; final KeyMaterial keys; + final AppSettings settings; final void Function(String) setStatus; - const _Body({required this.cfg, required this.keys, required this.setStatus}); + const _Body({ + required this.cfg, + required this.keys, + required this.settings, + required this.setStatus, + }); @override State<_Body> createState() => _BodyState(); @@ -85,15 +118,46 @@ class _BodyState extends State<_Body> { late final bundler = BundlerClient(widget.cfg['bundlerUrl']); final recipientCtl = TextEditingController(); final amountCtl = TextEditingController(text: '0.001'); + final PendingIndexStore pendingStore = PendingIndexStore(); + final ActivityStore activityStore = ActivityStore(); + late final ActivityPoller activityPoller; + + @override + void initState() { + super.initState(); + activityStore.load(); + activityPoller = ActivityPoller(store: activityStore, rpc: rpc, bundler: bundler); + activityPoller.start(); + } + + @override + void dispose() { + activityPoller.stop(); + super.dispose(); + } @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.all(16), children: [ + SizedBox(height: 200, child: ActivityFeed(store: activityStore)), + const SizedBox(height: 16), + _Card(child: SelectableText('ChainId: ${widget.cfg['chainId']}')), + const SizedBox(height: 8), _Card(child: SelectableText('Wallet: ${widget.cfg['walletAddress']}')), const SizedBox(height: 8), _Card(child: SelectableText('EntryPoint: ${widget.cfg['entryPoint']}')), + const SizedBox(height: 8), + _Card(child: SelectableText('Aggregator: ${widget.cfg['aggregator']}')), + const SizedBox(height: 8), + _Card( + child: SelectableText( + 'ProverRegistry: ${widget.cfg['proverRegistry']}')), + const SizedBox(height: 8), + _Card( + child: SelectableText( + 'ForceOnChainVerify: ${widget.cfg['forceOnChainVerify']}')), const SizedBox(height: 16), TextField( controller: recipientCtl, @@ -110,82 +174,63 @@ class _BodyState extends State<_Body> { onPressed: _sendEth, child: const Text('Send ETH (PQC)'))), ], ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _showPending, + child: const Text('Show Pending'))), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: _clearPending, + child: const Text('Clear Pending'))), + ], + ), ], ); } Future _sendEth() async { try { - widget.setStatus('Building UserOp...'); + widget.setStatus('Reading wallet state...'); final wallet = EthereumAddress.fromHex(widget.cfg['walletAddress']); - final entryPoint = EthereumAddress.fromHex(widget.cfg['entryPoint']); final to = EthereumAddress.fromHex(recipientCtl.text.trim()); + final amtStr = amountCtl.text.trim(); final amountWei = - EtherAmount.fromBase10String(EtherUnit.ether, amountCtl.text.trim()) - .getInWei; - - const execute = ContractFunction('execute', [ - FunctionParameter('to', AddressType()), - FunctionParameter('value', UintType()), - FunctionParameter('data', DynamicBytes()), - ]); - final callData = execute.encodeCall([to, amountWei, Uint8List(0)]); - - // Build userOp (gas fields filled after estimate) - final op = UserOperation() - ..sender = wallet.hex - ..nonce = BigInt.zero - ..callData = callData; - - // Estimate gas via bundler - final gas = await bundler.estimateUserOpGas(op.toJson(), entryPoint.hex); - op.callGasLimit = BigInt.parse(gas['callGasLimit'].toString()); - op.verificationGasLimit = - BigInt.parse(gas['verificationGasLimit'].toString()); - op.preVerificationGas = - BigInt.parse(gas['preVerificationGas'].toString()); + EtherAmount.fromBase10String(EtherUnit.ether, amtStr).getInWei; - // Get userOpHash from EntryPoint via eth_call - final userOpHash = await _getUserOpHash(entryPoint.hex, op); - // ECDSA sign - final creds = EthPrivateKey(Uint8List.fromList(widget.keys.ecdsaPriv)); - final sigBytes = await creds.signToUint8List(userOpHash, - chainId: null); // raw 32-byte hash - final eSig = w3.MsgSignature( - w3.bytesToInt(sigBytes.sublist(0, 32)), - w3.bytesToInt(sigBytes.sublist(32, 64)), - sigBytes[64], + final flow = UserOpFlow(rpc: rpc, bundler: bundler, store: pendingStore); + final uoh = await flow.sendEth( + cfg: widget.cfg, + keys: widget.keys, + to: to, + amountWei: amountWei, + settings: widget.settings, + log: widget.setStatus, + selectFees: (f) => showFeeSheet(context, f), ); - // WOTS sign/commit/nextCommit - final index = - 0; // MVP demo uses nonce 0; in production track wallet.nonce via RPC - final seedI = - hkdfIndex(Uint8List.fromList(widget.keys.wotsMaster), index); - final (sk, pk) = Wots.keygen(seedI); - final wSig = Wots.sign(userOpHash, sk); - final nextSeed = - hkdfIndex(Uint8List.fromList(widget.keys.wotsMaster), index + 1); - final (_, nextPk) = Wots.keygen(nextSeed); - final confirmCommit = Wots.commitPk(nextPk); - final nextNextSeed = - hkdfIndex(Uint8List.fromList(widget.keys.wotsMaster), index + 2); - final (_, nextNextPk) = Wots.keygen(nextNextSeed); - final proposeCommit = Wots.commitPk(nextNextPk); + await activityStore.upsertByUserOpHash(uoh, (existing) => + existing?.copyWith(status: ActivityStatus.sent) ?? + ActivityItem( + userOpHash: uoh, + to: to.hex, + display: '$amtStr ETH', + ts: DateTime.now().millisecondsSinceEpoch ~/ 1000, + status: ActivityStatus.sent, + chainId: widget.cfg['chainId'], + opKind: 'eth', + )); - // Pack signature - op.signature = - packHybridSignature(eSig, wSig, pk, confirmCommit, proposeCommit); - - widget.setStatus('Submitting to bundler...'); - final uoh = await bundler.sendUserOperation(op.toJson(), entryPoint.hex); widget.setStatus('Sent. UserOpHash: $uoh (waiting for receipt...)'); - // Poll for receipt for (int i = 0; i < 30; i++) { await Future.delayed(const Duration(seconds: 2)); final r = await bundler.getUserOperationReceipt(uoh); if (r != null) { + await pendingStore.clear(widget.cfg['chainId'], wallet.hex); widget .setStatus('Inclusion tx: ${r['receipt']['transactionHash']} ✅'); return; @@ -197,48 +242,20 @@ class _BodyState extends State<_Body> { } } - Future _getUserOpHash(String entryPoint, UserOperation op) async { - // Solidity selector: getUserOpHash((...)) - const userOpType = TupleType([ - AddressType(), - UintType(), - DynamicBytes(), - DynamicBytes(), - UintType(), - UintType(), - UintType(), - UintType(), - UintType(), - DynamicBytes(), - DynamicBytes(), - ]); - const getUserOpHashFn = ContractFunction( - 'getUserOpHash', - [FunctionParameter('op', userOpType)], - outputs: [FunctionParameter('', FixedBytes(32))], - ); - final data = getUserOpHashFn.encodeCall([ - [ - EthereumAddress.fromHex(op.sender), - op.nonce, - op.initCode, - op.callData, - op.callGasLimit, - op.verificationGasLimit, - op.preVerificationGas, - op.maxFeePerGas, - op.maxPriorityFeePerGas, - op.paymasterAndData, - Uint8List(0), - ] - ]); - final payload = { - 'to': entryPoint, - 'data': '0x${w3.bytesToHex(data, include0x: false)}' - }; - final res = await RpcClient(widget.cfg['rpcUrl']) - .call('eth_call', [payload, 'latest']); - return Uint8List.fromList(w3.hexToBytes(res.toString())); + Future _showPending() async { + final wallet = EthereumAddress.fromHex(widget.cfg['walletAddress']); + final chainId = widget.cfg['chainId']; + final pending = await pendingStore.load(chainId, wallet.hex); + widget.setStatus(pending == null + ? 'No pending record' + : const JsonEncoder.withIndent(' ').convert(pending)); + } + + Future _clearPending() async { + final wallet = EthereumAddress.fromHex(widget.cfg['walletAddress']); + final chainId = widget.cfg['chainId']; + await pendingStore.clear(chainId, wallet.hex); + widget.setStatus('pendingIndex cleared (canceled by user).'); } } diff --git a/mobile/lib/models/activity.dart b/mobile/lib/models/activity.dart new file mode 100644 index 0000000..3e66fd1 --- /dev/null +++ b/mobile/lib/models/activity.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +enum ActivityStatus { pending, sent, confirmed, failed, dropped } + +class ActivityItem { + final String userOpHash; + final String to; + final String display; + final int ts; + final ActivityStatus status; + final String? txHash; + final int chainId; + final String opKind; + final String? tokenSymbol; + final String? tokenAddress; + + const ActivityItem({ + required this.userOpHash, + required this.to, + required this.display, + required this.ts, + required this.status, + required this.chainId, + required this.opKind, + this.txHash, + this.tokenSymbol, + this.tokenAddress, + }); + + ActivityItem copyWith({ActivityStatus? status, String? txHash}) => ActivityItem( + userOpHash: userOpHash, + to: to, + display: display, + ts: ts, + status: status ?? this.status, + chainId: chainId, + opKind: opKind, + txHash: txHash ?? this.txHash, + tokenSymbol: tokenSymbol, + tokenAddress: tokenAddress, + ); + + Map toJson() => { + 'userOpHash': userOpHash, + 'to': to, + 'display': display, + 'ts': ts, + 'status': status.name, + 'txHash': txHash, + 'chainId': chainId, + 'opKind': opKind, + 'tokenSymbol': tokenSymbol, + 'tokenAddress': tokenAddress, + }; + + static ActivityItem fromJson(Map j) => ActivityItem( + userOpHash: j['userOpHash'], + to: j['to'], + display: j['display'], + ts: (j['ts'] as num).toInt(), + status: ActivityStatus.values.firstWhere( + (e) => e.name == j['status'], + orElse: () => ActivityStatus.sent), + txHash: j['txHash'], + chainId: (j['chainId'] as num).toInt(), + opKind: j['opKind'] ?? 'eth', + tokenSymbol: j['tokenSymbol'], + tokenAddress: j['tokenAddress'], + ); + + static String encodeList(List items) => + jsonEncode(items.map((e) => e.toJson()).toList()); + + static List decodeList(String s) => + (jsonDecode(s) as List) + .cast>() + .map(fromJson) + .toList(); +} diff --git a/mobile/lib/models/token.dart b/mobile/lib/models/token.dart new file mode 100644 index 0000000..1a6a5bc --- /dev/null +++ b/mobile/lib/models/token.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; + +class ChainTokens { + final Map raw; + ChainTokens(this.raw); + + String? tokenAddress(String symbol, int chainId) { + final t = (raw['tokens'] as List).firstWhere( + (e) => e['symbol'] == symbol, + orElse: () => null, + ); + if (t == null) return null; + return (t['addresses'] as Map)[chainId.toString()] as String?; + } + + Map? token(String symbol) { + return (raw['tokens'] as List) + .cast?>() + .firstWhere((e) => e?['symbol'] == symbol, orElse: () => null); + } + + bool feature(String symbol, String name) { + final t = token(symbol); + if (t == null) return false; + return (t['features']?[name] ?? false) as bool; + } + + String? permit2Address(int chainId) { + return (raw['permit2'] as Map)[chainId.toString()] as String?; + } + + int chainIdBase() => (raw['chainIds']['base'] as num).toInt(); + int chainIdBaseSepolia() => (raw['chainIds']['baseSepolia'] as num).toInt(); + + static Future load() async { + final s = await rootBundle.loadString('assets/tokens.base.json'); + return ChainTokens(jsonDecode(s) as Map); + } +} diff --git a/mobile/lib/services/activity_poller.dart b/mobile/lib/services/activity_poller.dart new file mode 100644 index 0000000..cdbb212 --- /dev/null +++ b/mobile/lib/services/activity_poller.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import '../models/activity.dart'; +import 'activity_store.dart'; +import 'rpc.dart'; +import 'bundler_client.dart'; + +class ActivityPoller { + final ActivityStore store; + final RpcClient rpc; + final BundlerClient bundler; + Timer? _timer; + + ActivityPoller({ + required this.store, + required this.rpc, + required this.bundler, + }); + + void start() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 10), (_) => _tick()); + _tick(); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + Future _tick() async { + final pending = store.items + .where((e) => e.status == ActivityStatus.pending || e.status == ActivityStatus.sent) + .toList(); + + for (final item in pending) { + try { + String? txHash = item.txHash; + if (txHash == null) { + final r = await bundler.getUserOperationReceipt(item.userOpHash); + if (r != null) { + final m = Map.from(r); + txHash = m['receipt']?['transactionHash'] as String?; + if (txHash != null) { + await store.setStatus(item.userOpHash, ActivityStatus.sent, txHash: txHash); + } + } + } + + if (txHash != null) { + final receipt = await rpc.call('eth_getTransactionReceipt', [txHash]); + if (receipt != null) { + final rm = Map.from(receipt); + final statusHex = rm['status'] as String?; + if (statusHex != null) { + final ok = statusHex == '0x1'; + await store.setStatus( + item.userOpHash, ok ? ActivityStatus.confirmed : ActivityStatus.failed); + } + } + } + } catch (_) { + // ignore network errors + } + } + } +} diff --git a/mobile/lib/services/activity_store.dart b/mobile/lib/services/activity_store.dart new file mode 100644 index 0000000..ab51cfb --- /dev/null +++ b/mobile/lib/services/activity_store.dart @@ -0,0 +1,55 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/activity.dart'; +import 'dart:async'; + +class ActivityStore { + static const _key = 'pqcwallet/activityFeed/v1'; + final _controller = StreamController>.broadcast(); + List _items = []; + + Stream> get stream => _controller.stream; + List get items => List.unmodifiable(_items); + + Future load() async { + final sp = await SharedPreferences.getInstance(); + final s = sp.getString(_key); + _items = s == null ? [] : ActivityItem.decodeList(s); + _controller.add(items); + } + + Future _save() async { + final sp = await SharedPreferences.getInstance(); + await sp.setString(_key, ActivityItem.encodeList(_items)); + _controller.add(items); + } + + Future add(ActivityItem item) async { + _items.insert(0, item); + if (_items.length > 200) _items.removeRange(200, _items.length); + await _save(); + } + + Future upsertByUserOpHash(String userOpHash, + ActivityItem Function(ActivityItem?) mutate) async { + final idx = _items.indexWhere( + (e) => e.userOpHash.toLowerCase() == userOpHash.toLowerCase()); + final existing = idx >= 0 ? _items[idx] : null; + final next = mutate(existing); + if (idx >= 0) { + _items[idx] = next; + } else { + _items.insert(0, next); + } + await _save(); + } + + Future setStatus(String userOpHash, ActivityStatus status, + {String? txHash}) async { + final idx = _items.indexWhere( + (e) => e.userOpHash.toLowerCase() == userOpHash.toLowerCase()); + if (idx < 0) return; + _items[idx] = + _items[idx].copyWith(status: status, txHash: txHash ?? _items[idx].txHash); + await _save(); + } +} diff --git a/mobile/lib/services/biometric.dart b/mobile/lib/services/biometric.dart new file mode 100644 index 0000000..e6b2ccb --- /dev/null +++ b/mobile/lib/services/biometric.dart @@ -0,0 +1,44 @@ +import 'package:local_auth/local_auth.dart'; + +class BiometricService { + final LocalAuthentication _auth = LocalAuthentication(); + + Future isDeviceSupported() async { + try { + return await _auth.isDeviceSupported(); + } catch (_) { + return false; + } + } + + Future canCheck() async { + try { + return await _auth.canCheckBiometrics || await _auth.isDeviceSupported(); + } catch (_) { + return false; + } + } + + /// Prompt the user to authenticate. Returns true if success, false if cancel/fail. + Future authenticate({required String reason}) async { + try { + return await _auth.authenticate( + localizedReason: reason, + options: const AuthenticationOptions( + biometricOnly: false, + stickyAuth: true, + useErrorDialogs: true, + sensitiveTransaction: true, + ), + ); + } catch (_) { + return false; + } + } + + Future cancel() async { + try { + await _auth.stopAuthentication(); + } catch (_) {} + } +} diff --git a/mobile/lib/services/bundler_client.dart b/mobile/lib/services/bundler_client.dart index 8cb6955..38035e5 100644 --- a/mobile/lib/services/bundler_client.dart +++ b/mobile/lib/services/bundler_client.dart @@ -5,25 +5,29 @@ class BundlerClient { final String url; BundlerClient(this.url); - Future> estimateUserOpGas(Map userOp, String entryPoint) async { + Future> estimateUserOpGas( + Map userOp, String entryPoint) async { final body = _rpc('eth_estimateUserOperationGas', [userOp, entryPoint]); final res = await http.post(Uri.parse(url), headers: _h(), body: body); _check(res); return (jsonDecode(res.body)['result'] as Map).cast(); } - Future sendUserOperation(Map userOp, String entryPoint) async { + Future sendUserOperation( + Map userOp, String entryPoint) async { final body = _rpc('eth_sendUserOperation', [userOp, entryPoint]); final res = await http.post(Uri.parse(url), headers: _h(), body: body); _check(res); return (jsonDecode(res.body)['result'] as String); } - Future?> getUserOperationReceipt(String userOpHash) async { + Future?> getUserOperationReceipt( + String userOpHash) async { final body = _rpc('eth_getUserOperationReceipt', [userOpHash]); final res = await http.post(Uri.parse(url), headers: _h(), body: body); final json = jsonDecode(res.body); - if (json['error'] != null) throw Exception('Bundler error: ${json['error']}'); + if (json['error'] != null) + throw Exception('Bundler error: ${json['error']}'); return json['result'] as Map?; } diff --git a/mobile/lib/services/entrypoint.dart b/mobile/lib/services/entrypoint.dart new file mode 100644 index 0000000..9fc4c49 --- /dev/null +++ b/mobile/lib/services/entrypoint.dart @@ -0,0 +1,61 @@ +import 'dart:typed_data'; +import 'package:web3dart/contracts.dart'; +import 'package:web3dart/web3dart.dart' as w3; +import 'package:web3dart/crypto.dart' as w3c; + +import 'rpc.dart'; +import '../userop/userop.dart'; + +class EntryPointService { + EntryPointService(this.rpc, this.entryPoint); + final RpcClient rpc; + final w3.EthereumAddress entryPoint; + + static final _fnGetUserOpHash = ContractFunction( + 'getUserOpHash', + [ + FunctionParameter( + 'op', + TupleType([ + AddressType(), + UintType(), + DynamicBytes(), + DynamicBytes(), + UintType(), + UintType(), + UintType(), + UintType(), + UintType(), + DynamicBytes(), + DynamicBytes(), + ])) + ], + outputs: [FunctionParameter('', FixedBytes(32))], + ); + + List _encodeOpForAbi(UserOperation op) => [ + w3.EthereumAddress.fromHex(op.sender), + op.nonce, + op.initCode, + op.callData, + op.callGasLimit, + op.verificationGasLimit, + op.preVerificationGas, + op.maxFeePerGas, + op.maxPriorityFeePerGas, + op.paymasterAndData, + op.signature, + ]; + + Future getUserOpHash(UserOperation op) async { + final data = _fnGetUserOpHash.encodeCall([ + _encodeOpForAbi(op), + ]); + final hex = + await rpc.callViewHex(entryPoint.hex, '0x${w3c.bytesToHex(data)}'); + final raw = w3c.hexToBytes(hex); + return raw.length == 32 + ? raw + : Uint8List.fromList(raw.sublist(raw.length - 32)); + } +} diff --git a/mobile/lib/services/erc20.dart b/mobile/lib/services/erc20.dart new file mode 100644 index 0000000..b2596d1 --- /dev/null +++ b/mobile/lib/services/erc20.dart @@ -0,0 +1,72 @@ +import 'dart:typed_data'; +import 'package:web3dart/contracts.dart'; +import 'package:web3dart/web3dart.dart' as w3; + +class Erc20 { + static final _fnTransfer = ContractFunction( + 'transfer', + [ + FunctionParameter('to', AddressType()), + FunctionParameter('amount', UintType()) + ], + outputs: [FunctionParameter('', BoolType())], + ); + + static final _fnApprove = ContractFunction( + 'approve', + [ + FunctionParameter('spender', AddressType()), + FunctionParameter('amount', UintType()) + ], + outputs: [FunctionParameter('', BoolType())], + ); + + static final _fnPermit = ContractFunction( + 'permit', + [ + FunctionParameter('owner', AddressType()), + FunctionParameter('spender', AddressType()), + FunctionParameter('value', UintType()), + FunctionParameter('deadline', UintType()), + FunctionParameter('v', UintType(length: 8)), + FunctionParameter('r', FixedBytes(32)), + FunctionParameter('s', FixedBytes(32)), + ], + outputs: const [], + ); + + static Uint8List encodeTransfer(String to, BigInt amount) => + _fnTransfer.encodeCall([ + w3.EthereumAddress.fromHex(to), + amount, + ]); + + static Uint8List encodeApprove(String spender, BigInt amount) => + _fnApprove.encodeCall([ + w3.EthereumAddress.fromHex(spender), + amount, + ]); + + static Uint8List encodePermit({ + required String owner, + required String spender, + required BigInt value, + required BigInt deadline, + required int v, + required Uint8List r, + required Uint8List s, + }) => + _fnPermit.encodeCall([ + w3.EthereumAddress.fromHex(owner), + w3.EthereumAddress.fromHex(spender), + value, + deadline, + BigInt.from(v), + r, + s, + ]); +} + +class Permit2 { + // Placeholder for future Permit2 encoders +} diff --git a/mobile/lib/services/rpc.dart b/mobile/lib/services/rpc.dart index ee45faf..19db182 100644 --- a/mobile/lib/services/rpc.dart +++ b/mobile/lib/services/rpc.dart @@ -12,7 +12,8 @@ class RpcClient { 'method': method, 'params': params ?? [], }); - final res = await http.post(Uri.parse(url), headers: {'Content-Type': 'application/json'}, body: body); + final res = await http.post(Uri.parse(url), + headers: {'Content-Type': 'application/json'}, body: body); if (res.statusCode != 200) { throw Exception('RPC ${res.statusCode}: ${res.body}'); } @@ -23,3 +24,30 @@ class RpcClient { return json['result']; } } + +extension RpcView on RpcClient { + /// Calls a contract view function with `to` and `data` (hex string with 0x). + Future callViewHex(String to, String dataHex) async { + final payload = {'to': to, 'data': dataHex}; + final res = await call('eth_call', [payload, 'latest']); + // Result is a hex string (0x...) + return res.toString(); + } + + /// Suggests a priority fee per gas in wei. + Future maxPriorityFeePerGas() async { + final hex = await call('eth_maxPriorityFeePerGas', []); + return BigInt.parse(hex.toString().substring(2), radix: 16); + } + + /// Returns fee history for recent blocks. + Future> feeHistory( + int blockCount, List rewardPercentiles) async { + final res = await call('eth_feeHistory', [ + '0x${blockCount.toRadixString(16)}', + 'latest', + rewardPercentiles.map((p) => p.toDouble()).toList() + ]); + return Map.from(res as Map); + } +} diff --git a/mobile/lib/services/storage.dart b/mobile/lib/services/storage.dart new file mode 100644 index 0000000..5e2b0cf --- /dev/null +++ b/mobile/lib/services/storage.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class PendingIndexStore { + final FlutterSecureStorage _ss = const FlutterSecureStorage(); + + String _key(int chainId, String wallet) => + 'pqcwallet/pendingIndex/\$chainId/\${wallet.toLowerCase()}'; + + Future save( + int chainId, String wallet, Map data) async { + await _ss.write(key: _key(chainId, wallet), value: jsonEncode(data)); + } + + Future?> load(int chainId, String wallet) async { + final s = await _ss.read(key: _key(chainId, wallet)); + if (s == null) return null; + return jsonDecode(s) as Map; + } + + Future clear(int chainId, String wallet) async { + await _ss.delete(key: _key(chainId, wallet)); + } +} diff --git a/mobile/lib/state/fees.dart b/mobile/lib/state/fees.dart new file mode 100644 index 0000000..9065bf4 --- /dev/null +++ b/mobile/lib/state/fees.dart @@ -0,0 +1,45 @@ +class FeeState { + final BigInt baseFee; + final BigInt prioritySuggestion; + final BigInt maxFeePerGas; + final BigInt maxPriorityFeePerGas; + final BigInt preVerificationGas; + final BigInt verificationGasLimit; + final BigInt callGasLimit; + final BigInt bundlerFeeWei; + const FeeState({ + required this.baseFee, + required this.prioritySuggestion, + required this.maxFeePerGas, + required this.maxPriorityFeePerGas, + required this.preVerificationGas, + required this.verificationGasLimit, + required this.callGasLimit, + required this.bundlerFeeWei, + }); + + BigInt get totalGas => + preVerificationGas + verificationGasLimit + callGasLimit; + BigInt get networkFeeWei => totalGas * maxFeePerGas; + BigInt get totalFeeWei => networkFeeWei + bundlerFeeWei; + + FeeState copyWith({ + BigInt? maxFeePerGas, + BigInt? maxPriorityFeePerGas, + BigInt? bundlerFeeWei, + }) => + FeeState( + baseFee: baseFee, + prioritySuggestion: prioritySuggestion, + maxFeePerGas: maxFeePerGas ?? this.maxFeePerGas, + maxPriorityFeePerGas: maxPriorityFeePerGas ?? this.maxPriorityFeePerGas, + preVerificationGas: preVerificationGas, + verificationGasLimit: verificationGasLimit, + callGasLimit: callGasLimit, + bundlerFeeWei: bundlerFeeWei ?? this.bundlerFeeWei, + ); +} + +String weiToEth(BigInt wei) => (wei / BigInt.from(10).pow(18)).toString(); +String weiToGwei(BigInt wei) => (wei / BigInt.from(10).pow(9)).toString(); +BigInt gweiToWei(String g) => BigInt.parse(g) * BigInt.from(10).pow(9); diff --git a/mobile/lib/state/settings.dart b/mobile/lib/state/settings.dart new file mode 100644 index 0000000..d63c81c --- /dev/null +++ b/mobile/lib/state/settings.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class AppSettings { + final bool biometricOnTestnets; + const AppSettings({this.biometricOnTestnets = false}); + + AppSettings copyWith({bool? biometricOnTestnets}) => AppSettings( + biometricOnTestnets: biometricOnTestnets ?? this.biometricOnTestnets); + + Map toJson() => { + 'biometricOnTestnets': biometricOnTestnets, + }; + + static AppSettings fromJson(Map json) => AppSettings( + biometricOnTestnets: json['biometricOnTestnets'] == true, + ); + + bool isTestnet(int chainId) => + const {84532, 11155111, 5, 80001}.contains(chainId); + + bool requireBiometricForChain(int chainId) => + chainId == 8453 || (isTestnet(chainId) && biometricOnTestnets); +} + +class SettingsStore { + final FlutterSecureStorage _ss = const FlutterSecureStorage(); + final String _key = 'pqcwallet/settings'; + + Future load() async { + final s = await _ss.read(key: _key); + if (s == null) return const AppSettings(); + return AppSettings.fromJson(jsonDecode(s) as Map); + } + + Future save(AppSettings s) async { + await _ss.write(key: _key, value: jsonEncode(s.toJson())); + } +} diff --git a/mobile/lib/ui/activity_feed.dart b/mobile/lib/ui/activity_feed.dart new file mode 100644 index 0000000..83915ba --- /dev/null +++ b/mobile/lib/ui/activity_feed.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import '../services/activity_store.dart'; +import '../models/activity.dart'; + +class ActivityFeed extends StatelessWidget { + final ActivityStore store; + const ActivityFeed({super.key, required this.store}); + + Color _statusColor(ActivityStatus s) { + switch (s) { + case ActivityStatus.pending: + return Colors.orange; + case ActivityStatus.sent: + return Colors.blueGrey; + case ActivityStatus.confirmed: + return Colors.green; + case ActivityStatus.failed: + return Colors.red; + case ActivityStatus.dropped: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: store.stream, + initialData: store.items, + builder: (context, snap) { + final items = snap.data ?? const []; + if (items.isEmpty) { + return const Center(child: Text('No activity yet')); + } + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final it = items[i]; + final shortTo = it.to.length > 10 + ? '${it.to.substring(0, 6)}…${it.to.substring(it.to.length - 4)}' + : it.to; + final shortHash = it.userOpHash.length > 10 + ? '${it.userOpHash.substring(0, 8)}…' + : it.userOpHash; + final when = + DateTime.fromMillisecondsSinceEpoch(it.ts * 1000).toLocal(); + return ListTile( + leading: CircleAvatar( + backgroundColor: _statusColor(it.status), + child: Text(it.opKind == 'erc20' + ? (it.tokenSymbol ?? 'T') + : 'Ξ'), + ), + title: Text('${it.display} → $shortTo'), + subtitle: Text('$shortHash • $when'), + trailing: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _statusColor(it.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text(it.status.name), + ), + ); + }, + ); + }, + ); + } +} diff --git a/mobile/lib/ui/send_sheet.dart b/mobile/lib/ui/send_sheet.dart new file mode 100644 index 0000000..7110605 --- /dev/null +++ b/mobile/lib/ui/send_sheet.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../state/fees.dart'; + +Future showFeeSheet(BuildContext context, FeeState fees) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _FeeSheet(initial: fees)); +} + +class _FeeSheet extends StatefulWidget { + final FeeState initial; + const _FeeSheet({required this.initial}); + + @override + State<_FeeSheet> createState() => _FeeSheetState(); +} + +class _FeeSheetState extends State<_FeeSheet> { + late FeeState _fees; + late TextEditingController _maxFeeCtl; + late TextEditingController _priorityCtl; + + @override + void initState() { + super.initState(); + _fees = widget.initial; + _maxFeeCtl = TextEditingController(text: weiToGwei(_fees.maxFeePerGas)); + _priorityCtl = + TextEditingController(text: weiToGwei(_fees.maxPriorityFeePerGas)); + _maxFeeCtl.addListener(_onChanged); + _priorityCtl.addListener(_onChanged); + } + + void _onChanged() { + setState(() { + _fees = _fees.copyWith( + maxFeePerGas: gweiToWei(_maxFeeCtl.text), + maxPriorityFeePerGas: gweiToWei(_priorityCtl.text), + ); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + left: 16, + right: 16, + top: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Fee Preview', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + _row('Base fee (gwei)', weiToGwei(_fees.baseFee)), + _row('Priority suggestion (gwei)', + weiToGwei(_fees.prioritySuggestion)), + _row('Gas total', _fees.totalGas.toString()), + const SizedBox(height: 8), + TextField( + controller: _priorityCtl, + keyboardType: TextInputType.number, + decoration: + const InputDecoration(labelText: 'Max priority fee (gwei)'), + ), + const SizedBox(height: 8), + TextField( + controller: _maxFeeCtl, + keyboardType: TextInputType.number, + decoration: + const InputDecoration(labelText: 'Max fee per gas (gwei)'), + ), + const SizedBox(height: 12), + _row('Network fee (ETH)', weiToEth(_fees.networkFeeWei)), + _row('Bundler fee (ETH)', weiToEth(_fees.bundlerFeeWei)), + _row('Total fee (ETH)', weiToEth(_fees.totalFeeWei)), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel')), + const Spacer(), + TextButton( + onPressed: () { + final s = widget.initial; + _maxFeeCtl.text = weiToGwei(s.maxFeePerGas); + _priorityCtl.text = weiToGwei(s.maxPriorityFeePerGas); + }, + child: const Text('Use suggestions')), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => Navigator.pop(context, _fees), + child: const Text('Confirm')), + ], + ) + ], + ), + ); + } + + Widget _row(String label, String value) => + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text(label), + Text(value), + ]); +} diff --git a/mobile/lib/ui/send_token_sheet.dart b/mobile/lib/ui/send_token_sheet.dart new file mode 100644 index 0000000..04edc55 --- /dev/null +++ b/mobile/lib/ui/send_token_sheet.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import '../models/token.dart'; +import '../userop/userop_flow.dart'; +import '../crypto/mnemonic.dart'; +import '../state/settings.dart'; +import '../models/activity.dart'; +import '../services/activity_store.dart'; + +class SendTokenSheet extends StatefulWidget { + final Map cfg; + final UserOpFlow flow; + final KeyMaterial keys; + final AppSettings settings; + final ActivityStore store; + const SendTokenSheet({ + super.key, + required this.cfg, + required this.flow, + required this.keys, + required this.settings, + required this.store, + }); + + @override + State createState() => _SendTokenSheetState(); +} + +class _SendTokenSheetState extends State { + ChainTokens? registry; + String selected = 'USDC'; + final toCtrl = TextEditingController(); + final amtCtrl = TextEditingController(); + bool usePermit = false; + bool usePermit2 = false; + bool sending = false; + + @override + void initState() { + super.initState(); + ChainTokens.load().then((r) => setState(() => registry = r)); + } + + BigInt _pow10(int d) => BigInt.from(10).pow(d); + + BigInt _parseAmount(String v, int decimals) { + final parts = v.split('.'); + final whole = parts[0].isEmpty ? BigInt.zero : BigInt.parse(parts[0]); + var frac = parts.length > 1 ? parts[1] : ''; + if (frac.length > decimals) { + frac = frac.substring(0, decimals); + } + final fracVal = + frac.isEmpty ? BigInt.zero : BigInt.parse(frac.padRight(decimals, '0')); + return whole * _pow10(decimals) + fracVal; + } + + Future _send() async { + if (registry == null) return; + final token = selected; + final tokenInfo = registry!.token(token)!; + final decimals = (tokenInfo['decimals'] as num).toInt(); + final amtStr = amtCtrl.text.trim(); + final amount = _parseAmount(amtStr, decimals); + final to = toCtrl.text.trim(); + final chainId = widget.cfg['chainId'] as int; + final tokenAddr = registry!.tokenAddress(token, chainId)!; + setState(() => sending = true); + try { + final uoh = await widget.flow.sendToken( + cfg: widget.cfg, + keys: widget.keys, + tokenSymbol: token, + recipient: to, + amountWeiLike: amount, + registry: registry!, + wantErc2612: usePermit, + wantPermit2: usePermit2, + settings: widget.settings, + log: (m) => debugPrint(m), + selectFees: (f) async => f, + ); + await widget.store.upsertByUserOpHash(uoh, (existing) => + existing?.copyWith(status: ActivityStatus.sent) ?? + ActivityItem( + userOpHash: uoh, + to: to, + display: '$amtStr $token', + ts: DateTime.now().millisecondsSinceEpoch ~/ 1000, + status: ActivityStatus.sent, + chainId: chainId, + opKind: 'erc20', + tokenSymbol: token, + tokenAddress: tokenAddr, + )); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + debugPrint('send error: $e'); + } finally { + if (mounted) setState(() => sending = false); + } + } + + @override + Widget build(BuildContext context) { + final tokens = registry?.raw['tokens'] as List? ?? []; + final permitDisabled = !(registry?.feature(selected, 'erc2612') ?? false); + final permit2Disabled = + registry?.permit2Address(widget.cfg['chainId'] as int) == null || + !(registry?.feature(selected, 'permit2') ?? false); + return Scaffold( + appBar: AppBar(title: const Text('Send Token')), + body: registry == null + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + DropdownButton( + value: selected, + items: tokens + .map>((e) => DropdownMenuItem( + value: e['symbol'] as String, + child: Text(e['symbol'] as String), + )) + .toList(), + onChanged: (v) => setState(() => selected = v ?? selected), + ), + TextField( + controller: toCtrl, + decoration: + const InputDecoration(labelText: 'Recipient (0x...)'), + ), + TextField( + controller: amtCtrl, + decoration: const InputDecoration(labelText: 'Amount'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + ), + SwitchListTile( + value: usePermit, + onChanged: permitDisabled + ? null + : (v) => setState(() => usePermit = v), + title: const Text('Use EIP-2612 permit'), + ), + SwitchListTile( + value: usePermit2, + onChanged: permit2Disabled + ? null + : (v) => setState(() => usePermit2 = v), + title: const Text('Use Permit2'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: sending ? null : _send, + child: const Text('Send'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/settings_screen.dart b/mobile/lib/ui/settings_screen.dart new file mode 100644 index 0000000..1d2438f --- /dev/null +++ b/mobile/lib/ui/settings_screen.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import '../state/settings.dart'; + +class SettingsScreen extends StatefulWidget { + final AppSettings settings; + final SettingsStore store; + const SettingsScreen( + {super.key, required this.settings, required this.store}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late AppSettings _s; + + @override + void initState() { + super.initState(); + _s = widget.settings; + } + + Future _toggle(bool v) async { + setState(() => _s = _s.copyWith(biometricOnTestnets: v)); + await widget.store.save(_s); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + SwitchListTile( + title: const Text('Require biometric on testnets'), + subtitle: const Text( + 'When enabled, you must authenticate before signing on testnets. Always required on mainnet.'), + value: _s.biometricOnTestnets, + onChanged: _toggle, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/userop/userop_flow.dart b/mobile/lib/userop/userop_flow.dart new file mode 100644 index 0000000..4bcc86c --- /dev/null +++ b/mobile/lib/userop/userop_flow.dart @@ -0,0 +1,529 @@ +import 'dart:typed_data'; +import 'package:web3dart/crypto.dart' as w3; +import 'package:web3dart/web3dart.dart'; + +import '../crypto/mnemonic.dart'; +import '../crypto/wots.dart'; +import '../services/bundler_client.dart'; +import '../services/rpc.dart'; +import '../services/storage.dart'; +import '../services/biometric.dart'; +import '../services/entrypoint.dart'; +import '../state/fees.dart'; +import '../state/settings.dart'; +import '../userop/userop.dart'; +import '../userop/userop_signer.dart'; +import '../models/token.dart'; +import '../services/erc20.dart'; + +class UserOpFlow { + final RpcClient rpc; + final BundlerClient bundler; + final PendingIndexStore store; + + UserOpFlow({required this.rpc, required this.bundler, required this.store}); + + Future sendEth({ + required Map cfg, + required KeyMaterial keys, + required EthereumAddress to, + required BigInt amountWei, + required AppSettings settings, + required void Function(String) log, + required Future Function(FeeState) selectFees, + }) async { + final chainId = cfg['chainId'] as int; + final wallet = EthereumAddress.fromHex(cfg['walletAddress']); + final entryPoint = EthereumAddress.fromHex(cfg['entryPoint']); + + // view function encodings + const fnNonce = ContractFunction('nonce', [], + outputs: [FunctionParameter('', UintType())]); + const fnCurrent = ContractFunction('currentPkCommit', [], + outputs: [FunctionParameter('', FixedBytes(32))]); + const fnNext = ContractFunction('nextPkCommit', [], + outputs: [FunctionParameter('', FixedBytes(32))]); + final dataNonce = fnNonce.encodeCall(const []); + final dataCur = fnCurrent.encodeCall(const []); + final dataNext = fnNext.encodeCall(const []); + + final nonceHex = await rpc.callViewHex( + wallet.hex, '0x${w3.bytesToHex(dataNonce, include0x: false)}'); + final curHex = await rpc.callViewHex( + wallet.hex, '0x${w3.bytesToHex(dataCur, include0x: false)}'); + final nextHex = await rpc.callViewHex( + wallet.hex, '0x${w3.bytesToHex(dataNext, include0x: false)}'); + + BigInt parseHexBigInt(String h) { + final s = h.startsWith('0x') ? h.substring(2) : h; + return s.isEmpty ? BigInt.zero : BigInt.parse(s, radix: 16); + } + + Uint8List parseHex32(String h) { + final b = w3.hexToBytes(h); + if (b.length == 32) return Uint8List.fromList(b); + if (b.length > 32) return Uint8List.fromList(b.sublist(b.length - 32)); + final out = Uint8List(32); + out.setRange(32 - b.length, 32, b); + return out; + } + + final nonceOnChain = parseHexBigInt(nonceHex); + final currentCommitOnChain = parseHex32(curHex); + final nextCommitOnChain = parseHex32(nextHex); + log('currentPkCommit: 0x${w3.bytesToHex(currentCommitOnChain, include0x: true)}'); + + final pending = await store.load(chainId, wallet.hex); + final callData = _encodeExecute(to, amountWei); + + final op = UserOperation() + ..sender = wallet.hex + ..nonce = nonceOnChain + ..callData = callData; + + final gas = await bundler.estimateUserOpGas(op.toJson(), entryPoint.hex); + op.callGasLimit = BigInt.parse(gas['callGasLimit'].toString()); + op.verificationGasLimit = + BigInt.parse(gas['verificationGasLimit'].toString()); + op.preVerificationGas = BigInt.parse(gas['preVerificationGas'].toString()); + + final fh = await rpc.feeHistory(5, [50]); + final baseFees = (fh['baseFeePerGas'] as List) + .map((h) => BigInt.parse(h.toString().substring(2), radix: 16)) + .toList(); + final baseFee = baseFees.isNotEmpty ? baseFees.last : BigInt.zero; + + BigInt priority = BigInt.zero; + final rewards = fh['reward'] as List?; + if (rewards != null && rewards.isNotEmpty) { + final lastRewards = (rewards.last as List).cast(); + if (lastRewards.isNotEmpty) { + priority = BigInt.parse(lastRewards.first.substring(2), radix: 16); + } + } + if (priority == BigInt.zero) { + priority = await rpc.maxPriorityFeePerGas(); + } + final suggestedMaxFee = baseFee + priority; + + var fees = FeeState( + baseFee: baseFee, + prioritySuggestion: priority, + maxFeePerGas: suggestedMaxFee, + maxPriorityFeePerGas: priority, + preVerificationGas: op.preVerificationGas, + verificationGasLimit: op.verificationGasLimit, + callGasLimit: op.callGasLimit, + bundlerFeeWei: BigInt.zero, + ); + + final chosen = await selectFees(fees); + if (chosen == null) { + throw Exception('fee-canceled'); + } + fees = chosen; + + op.maxFeePerGas = fees.maxFeePerGas; + op.maxPriorityFeePerGas = fees.maxPriorityFeePerGas; + + final ep = EntryPointService(rpc, entryPoint); + final userOpHash = await ep.getUserOpHash(op); + final userOpHashHex = '0x${w3.bytesToHex(userOpHash, include0x: false)}'; + + final mustAuth = settings.requireBiometricForChain(chainId); + if (mustAuth) { + final bio = BiometricService(); + final can = await bio.canCheck(); + if (!can) { + log('Biometric requested but unavailable. Aborting.'); + throw Exception('biometric-unavailable'); + } + final ok = await bio.authenticate( + reason: 'Authenticate to sign & send this transaction'); + if (!ok) { + log('Authentication canceled/failed. Send aborted.'); + throw Exception('auth-failed'); + } + log('Authentication successful.'); + } + + final now = DateTime.now().toUtc().toIso8601String(); + String decision; + Map? record = pending; + + if (pending != null && + pending['userOpHash'] == userOpHashHex && + pending['index'] == nonceOnChain.toInt() && + (pending['entryPoint'] as String).toLowerCase() == + entryPoint.hex.toLowerCase() && + pending['networkChainId'] == chainId) { + // reuse + decision = 'reuse'; + op.signature = + Uint8List.fromList(w3.hexToBytes(pending['signatureHybrid'])); + pending['status'] = 'sent'; + pending['lastAttemptAt'] = now; + await store.save(chainId, wallet.hex, pending); + } else { + if (pending != null && pending['index'] != nonceOnChain.toInt()) { + await store.clear(chainId, wallet.hex); + decision = 'stale-clear'; + } else if (pending != null) { + decision = 'rebuild'; + } else { + decision = 'fresh'; + } + + // build new signature + final creds = EthPrivateKey(Uint8List.fromList(keys.ecdsaPriv)); + final sigBytes = await creds.signToUint8List(userOpHash, chainId: null); + final eSig = w3.MsgSignature( + w3.bytesToInt(sigBytes.sublist(0, 32)), + w3.bytesToInt(sigBytes.sublist(32, 64)), + sigBytes[64], + ); + + final index = nonceOnChain.toInt(); + final seedI = hkdfIndex(Uint8List.fromList(keys.wotsMaster), index); + final (sk, pk) = Wots.keygen(seedI); + final wSig = Wots.sign(userOpHash, sk); + + final confirmCommit = nextCommitOnChain; + final nextNextSeed = + hkdfIndex(Uint8List.fromList(keys.wotsMaster), index + 2); + final (_, nextNextPk) = Wots.keygen(nextNextSeed); + final proposeCommit = Wots.commitPk(nextNextPk); + + op.signature = + packHybridSignature(eSig, wSig, pk, confirmCommit, proposeCommit); + + final pkBytes = pk.expand((e) => e).toList(); + record = { + 'version': 1, + 'wallet': wallet.hex, + 'entryPoint': entryPoint.hex, + 'networkChainId': chainId, + 'userOpHash': userOpHashHex, + 'nonce': nonceOnChain.toString(), + 'index': index, + 'signatureHybrid': '0x${w3.bytesToHex(op.signature, include0x: false)}', + 'confirmNextCommit': + '0x${w3.bytesToHex(confirmCommit, include0x: false)}', + 'proposeNextCommit': + '0x${w3.bytesToHex(proposeCommit, include0x: false)}', + 'wotsPk': '0x${w3.bytesToHex(pkBytes, include0x: false)}', + 'status': 'pending', + 'createdAt': now, + 'lastAttemptAt': now, + }; + if (pending != null && decision == 'rebuild') { + record['createdAt'] = pending['createdAt']; + } + await store.save(chainId, wallet.hex, record); + } + + final pendingStatus = pending == null ? 'absent' : 'present'; + log([ + 'pendingIndex: $pendingStatus', + 'nonce(): ${nonceOnChain.toString()}', + 'userOpHash(draft): ${userOpHashHex.substring(0, 10)}', + 'decision: $decision', + ].join('\n')); + + final uoh = await bundler.sendUserOperation(op.toJson(), entryPoint.hex); + record ??= await store.load(chainId, wallet.hex); + if (record != null) { + record['status'] = 'sent'; + record['lastAttemptAt'] = now; + await store.save(chainId, wallet.hex, record); + } + return uoh; + } + + Future sendToken({ + required Map cfg, + required KeyMaterial keys, + required String tokenSymbol, + required String recipient, + required BigInt amountWeiLike, + required ChainTokens registry, + required bool wantErc2612, + required bool wantPermit2, + required AppSettings settings, + required void Function(String) log, + required Future Function(FeeState) selectFees, + }) async { + final chainId = cfg['chainId'] as int; + final wallet = EthereumAddress.fromHex(cfg['walletAddress']); + final entryPoint = EthereumAddress.fromHex(cfg['entryPoint']); + + const fnNonce = ContractFunction('nonce', [], + outputs: [FunctionParameter('', UintType())]); + const fnCurrent = ContractFunction('currentPkCommit', [], + outputs: [FunctionParameter('', FixedBytes(32))]); + const fnNext = ContractFunction('nextPkCommit', [], + outputs: [FunctionParameter('', FixedBytes(32))]); + final dataNonce = fnNonce.encodeCall(const []); + final dataCur = fnCurrent.encodeCall(const []); + final dataNext = fnNext.encodeCall(const []); + + final nonceHex = await rpc.callViewHex( + wallet.hex, '0x${w3.bytesToHex(dataNonce, include0x: false)}'); + final curHex = await rpc.callViewHex( + wallet.hex, '0x${w3.bytesToHex(dataCur, include0x: false)}'); + final nextHex = await rpc.callViewHex( + wallet.hex, '0x${w3.bytesToHex(dataNext, include0x: false)}'); + + BigInt parseHexBigInt(String h) { + final s = h.startsWith('0x') ? h.substring(2) : h; + return s.isEmpty ? BigInt.zero : BigInt.parse(s, radix: 16); + } + + Uint8List parseHex32(String h) { + final b = w3.hexToBytes(h); + if (b.length == 32) return Uint8List.fromList(b); + if (b.length > 32) return Uint8List.fromList(b.sublist(b.length - 32)); + final out = Uint8List(32); + out.setRange(32 - b.length, 32, b); + return out; + } + + final nonceOnChain = parseHexBigInt(nonceHex); + final currentCommitOnChain = parseHex32(curHex); + final nextCommitOnChain = parseHex32(nextHex); + log('currentPkCommit: 0x${w3.bytesToHex(currentCommitOnChain, include0x: true)}'); + + final pending = await store.load(chainId, wallet.hex); + final callData = await buildTokenSendBatch( + chainId: chainId, + wallet: wallet.hex, + tokenSymbol: tokenSymbol, + recipient: recipient, + amountWeiLike: amountWeiLike, + wantErc2612: wantErc2612, + wantPermit2: wantPermit2, + registry: registry, + ); + + final op = UserOperation() + ..sender = wallet.hex + ..nonce = nonceOnChain + ..callData = callData; + + final gas = await bundler.estimateUserOpGas(op.toJson(), entryPoint.hex); + op.callGasLimit = BigInt.parse(gas['callGasLimit'].toString()); + op.verificationGasLimit = + BigInt.parse(gas['verificationGasLimit'].toString()); + op.preVerificationGas = BigInt.parse(gas['preVerificationGas'].toString()); + + final fh = await rpc.feeHistory(5, [50]); + final baseFees = (fh['baseFeePerGas'] as List) + .map((h) => BigInt.parse(h.toString().substring(2), radix: 16)) + .toList(); + final baseFee = baseFees.isNotEmpty ? baseFees.last : BigInt.zero; + + BigInt priority = BigInt.zero; + final rewards = fh['reward'] as List?; + if (rewards != null && rewards.isNotEmpty) { + final lastRewards = (rewards.last as List).cast(); + if (lastRewards.isNotEmpty) { + priority = BigInt.parse(lastRewards.first.substring(2), radix: 16); + } + } + if (priority == BigInt.zero) { + priority = await rpc.maxPriorityFeePerGas(); + } + final suggestedMaxFee = baseFee + priority; + + var fees = FeeState( + baseFee: baseFee, + prioritySuggestion: priority, + maxFeePerGas: suggestedMaxFee, + maxPriorityFeePerGas: priority, + preVerificationGas: op.preVerificationGas, + verificationGasLimit: op.verificationGasLimit, + callGasLimit: op.callGasLimit, + bundlerFeeWei: BigInt.zero, + ); + + final chosen = await selectFees(fees); + if (chosen == null) { + throw Exception('fee-canceled'); + } + fees = chosen; + + op.maxFeePerGas = fees.maxFeePerGas; + op.maxPriorityFeePerGas = fees.maxPriorityFeePerGas; + + final ep = EntryPointService(rpc, entryPoint); + final userOpHash = await ep.getUserOpHash(op); + final userOpHashHex = '0x${w3.bytesToHex(userOpHash, include0x: false)}'; + + final mustAuth = settings.requireBiometricForChain(chainId); + if (mustAuth) { + final bio = BiometricService(); + final can = await bio.canCheck(); + if (!can) { + log('Biometric requested but unavailable. Aborting.'); + throw Exception('biometric-unavailable'); + } + final ok = await bio.authenticate( + reason: 'Authenticate to sign & send this transaction'); + if (!ok) { + log('Authentication canceled/failed. Send aborted.'); + throw Exception('auth-failed'); + } + log('Authentication successful.'); + } + + final now = DateTime.now().toUtc().toIso8601String(); + String decision; + Map? record = pending; + + if (pending != null && + pending['userOpHash'] == userOpHashHex && + pending['index'] == nonceOnChain.toInt() && + (pending['entryPoint'] as String).toLowerCase() == + entryPoint.hex.toLowerCase() && + pending['networkChainId'] == chainId) { + decision = 'reuse'; + op.signature = + Uint8List.fromList(w3.hexToBytes(pending['signatureHybrid'])); + pending['status'] = 'sent'; + pending['lastAttemptAt'] = now; + await store.save(chainId, wallet.hex, pending); + } else { + if (pending != null && pending['index'] != nonceOnChain.toInt()) { + await store.clear(chainId, wallet.hex); + decision = 'stale-clear'; + } else if (pending != null) { + decision = 'rebuild'; + } else { + decision = 'fresh'; + } + + final creds = EthPrivateKey(Uint8List.fromList(keys.ecdsaPriv)); + final sigBytes = await creds.signToUint8List(userOpHash, chainId: null); + final eSig = w3.MsgSignature( + w3.bytesToInt(sigBytes.sublist(0, 32)), + w3.bytesToInt(sigBytes.sublist(32, 64)), + sigBytes[64], + ); + + final index = nonceOnChain.toInt(); + final seedI = hkdfIndex(Uint8List.fromList(keys.wotsMaster), index); + final (sk, pk) = Wots.keygen(seedI); + final wSig = Wots.sign(userOpHash, sk); + + final confirmCommit = nextCommitOnChain; + final nextNextSeed = + hkdfIndex(Uint8List.fromList(keys.wotsMaster), index + 2); + final (_, nextNextPk) = Wots.keygen(nextNextSeed); + final proposeCommit = Wots.commitPk(nextNextPk); + + op.signature = + packHybridSignature(eSig, wSig, pk, confirmCommit, proposeCommit); + + final pkBytes = pk.expand((e) => e).toList(); + record = { + 'version': 1, + 'wallet': wallet.hex, + 'entryPoint': entryPoint.hex, + 'networkChainId': chainId, + 'userOpHash': userOpHashHex, + 'nonce': nonceOnChain.toString(), + 'index': index, + 'signatureHybrid': '0x${w3.bytesToHex(op.signature, include0x: false)}', + 'confirmNextCommit': + '0x${w3.bytesToHex(confirmCommit, include0x: false)}', + 'proposeNextCommit': + '0x${w3.bytesToHex(proposeCommit, include0x: false)}', + 'wotsPk': '0x${w3.bytesToHex(pkBytes, include0x: false)}', + 'status': 'pending', + 'createdAt': now, + 'lastAttemptAt': now, + }; + if (pending != null && decision == 'rebuild') { + record['createdAt'] = pending['createdAt']; + } + await store.save(chainId, wallet.hex, record); + } + + final pendingStatus = pending == null ? 'absent' : 'present'; + log([ + 'pendingIndex: $pendingStatus', + 'nonce(): ${nonceOnChain.toString()}', + 'userOpHash(draft): ${userOpHashHex.substring(0, 10)}', + 'decision: $decision', + ].join('\n')); + + final uoh = await bundler.sendUserOperation(op.toJson(), entryPoint.hex); + record ??= await store.load(chainId, wallet.hex); + if (record != null) { + record['status'] = 'sent'; + record['lastAttemptAt'] = now; + await store.save(chainId, wallet.hex, record); + } + return uoh; + } + + Uint8List _encodeExecute(EthereumAddress to, BigInt value) { + const execute = ContractFunction('execute', [ + FunctionParameter('to', AddressType()), + FunctionParameter('value', UintType()), + FunctionParameter('data', DynamicBytes()), + ]); + return execute.encodeCall([to, value, Uint8List(0)]); + } +} + +class Call { + final String to; + final BigInt value; + final Uint8List data; + Call(this.to, this.value, this.data); +} + +Uint8List encodeExecuteBatch(List calls) { + final executeBatch = ContractFunction('executeBatch', [ + FunctionParameter( + 'calls', + DynamicLengthArray( + type: TupleType([AddressType(), UintType(), DynamicBytes()]))) + ]); + final params = calls + .map((c) => [EthereumAddress.fromHex(c.to), c.value, c.data]) + .toList(); + return executeBatch.encodeCall([params]); +} + +Future buildTokenSendBatch({ + required int chainId, + required String wallet, + required String tokenSymbol, + required String recipient, + required BigInt amountWeiLike, + required bool wantErc2612, + required bool wantPermit2, + required ChainTokens registry, +}) async { + final tokenAddr = registry.tokenAddress(tokenSymbol, chainId)!; + + final calls = []; + + if (wantErc2612 && registry.feature(tokenSymbol, 'erc2612')) { + // Path A not supported for smart contract owners; fallback to transfer + } + + if (wantPermit2 && registry.feature(tokenSymbol, 'permit2')) { + final permit2 = registry.permit2Address(chainId); + if (permit2 != null) { + // Path B not supported; fallback + } + } + + calls.add(Call( + tokenAddr, BigInt.zero, Erc20.encodeTransfer(recipient, amountWeiLike))); + + return encodeExecuteBatch(calls); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d50da48..a888dcd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -126,6 +134,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + url: "https://pub.dev" + source: hosted + version: "2.0.30" flutter_secure_storage: dependency: "direct main" description: @@ -208,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" js: dependency: transitive description: @@ -256,6 +280,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" + url: "https://pub.dev" + source: hosted + version: "1.0.52" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" matcher: dependency: transitive description: @@ -368,6 +432,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + url: "https://pub.dev" + source: hosted + version: "2.4.12" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0c48366..6bc26bf 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -15,7 +15,9 @@ dependencies: bip32: ^2.0.0 crypto: ^3.0.3 flutter_secure_storage: ^9.0.0 + local_auth: ^2.3.0 collection: ^1.18.0 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: @@ -26,3 +28,4 @@ flutter: uses-material-design: true assets: - assets/config.example.json + - assets/tokens.base.json diff --git a/mobile/test/activity_store_test.dart b/mobile/test/activity_store_test.dart new file mode 100644 index 0000000..7c4bf67 --- /dev/null +++ b/mobile/test/activity_store_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pqc_wallet/models/activity.dart'; +import 'package:pqc_wallet/services/activity_store.dart'; + +void main() { + test('store add and update', () async { + SharedPreferences.setMockInitialValues({}); + final store = ActivityStore(); + await store.load(); + final item = ActivityItem( + userOpHash: '0xabc', + to: '0x1', + display: '1 ETH', + ts: 1, + status: ActivityStatus.sent, + chainId: 1, + opKind: 'eth', + ); + await store.add(item); + expect(store.items.length, 1); + await store.setStatus('0xabc', ActivityStatus.confirmed, txHash: '0xdef'); + expect(store.items.first.status, ActivityStatus.confirmed); + expect(store.items.first.txHash, '0xdef'); + }); +} diff --git a/mobile/test/app_settings_test.dart b/mobile/test/app_settings_test.dart new file mode 100644 index 0000000..fc51d74 --- /dev/null +++ b/mobile/test/app_settings_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pqc_wallet/state/settings.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + FlutterSecureStorage.setMockInitialValues({}); + + test('settings persistence and gating logic', () async { + final store = SettingsStore(); + var s = await store.load(); + expect(s.biometricOnTestnets, isFalse); + expect(s.requireBiometricForChain(8453), isTrue); + expect(s.requireBiometricForChain(84532), isFalse); + s = s.copyWith(biometricOnTestnets: true); + await store.save(s); + final loaded = await store.load(); + expect(loaded.biometricOnTestnets, isTrue); + expect(loaded.requireBiometricForChain(84532), isTrue); + expect(loaded.requireBiometricForChain(8453), isTrue); + }); +} diff --git a/mobile/test/pending_index_store_test.dart b/mobile/test/pending_index_store_test.dart new file mode 100644 index 0000000..19cd729 --- /dev/null +++ b/mobile/test/pending_index_store_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pqc_wallet/services/storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + FlutterSecureStorage.setMockInitialValues({}); + + test('save/load/clear pending index record', () async { + final store = PendingIndexStore(); + final chainId = 1; + final wallet = '0xabc'; + final data = {'version': 1, 'foo': 'bar'}; + await store.save(chainId, wallet, data); + final loaded = await store.load(chainId, wallet); + expect(loaded, isNotNull); + expect(loaded!['foo'], 'bar'); + await store.clear(chainId, wallet); + final cleared = await store.load(chainId, wallet); + expect(cleared, isNull); + }); +} diff --git a/mobile/test/rpc_view_test.dart b/mobile/test/rpc_view_test.dart new file mode 100644 index 0000000..c7a02f5 --- /dev/null +++ b/mobile/test/rpc_view_test.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pqc_wallet/services/rpc.dart'; + +void main() { + test('callViewHex performs eth_call and returns hex result', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async => server.close(force: true)); + + server.listen((HttpRequest request) async { + final body = await utf8.decoder.bind(request).join(); + final json = jsonDecode(body) as Map; + expect(json['method'], 'eth_call'); + final response = jsonEncode( + {'jsonrpc': '2.0', 'id': json['id'], 'result': '0xdeadbeef'}); + request.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(response); + await request.response.close(); + }); + + final client = RpcClient('http://${server.address.host}:${server.port}'); + final res = await client.callViewHex('0xabc', '0x123'); + expect(res, '0xdeadbeef'); + }); +} diff --git a/mobile/test/token_model_test.dart b/mobile/test/token_model_test.dart new file mode 100644 index 0000000..b4cc33b --- /dev/null +++ b/mobile/test/token_model_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pqc_wallet/models/token.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('loads token registry', () async { + final registry = await ChainTokens.load(); + expect(registry.chainIdBaseSepolia(), 84532); + final addr = registry.tokenAddress('USDC', 84532); + expect(addr, isNotNull); + }); +} diff --git a/mobile/test/userop_flow_test.dart b/mobile/test/userop_flow_test.dart new file mode 100644 index 0000000..fdba490 --- /dev/null +++ b/mobile/test/userop_flow_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web3dart/web3dart.dart'; +import 'package:pqc_wallet/crypto/mnemonic.dart'; +import 'package:pqc_wallet/services/storage.dart'; +import 'package:pqc_wallet/userop/userop_flow.dart'; +import 'package:pqc_wallet/services/rpc.dart'; +import 'package:pqc_wallet/services/bundler_client.dart'; +import 'package:pqc_wallet/state/settings.dart'; + +class MockRpc extends RpcClient { + int _i = 0; + MockRpc() : super(''); + @override + Future call(String method, [dynamic params]) async { + if (method == 'eth_call') { + switch (_i++) { + case 0: + return '0x1'; + case 1: + return '0x' + '00' * 32; + case 2: + return '0x' + '22' * 32; + case 3: + return '0x' + '11' * 32; + case 4: + return '0x1'; + case 5: + return '0x' + '00' * 32; + case 6: + return '0x' + '22' * 32; + default: + return '0x' + '11' * 32; + } + } + if (method == 'eth_feeHistory') { + return { + 'baseFeePerGas': ['0x1', '0x1'], + 'reward': [ + ['0x1'] + ] + }; + } + if (method == 'eth_maxPriorityFeePerGas') { + return '0x1'; + } + throw UnimplementedError(); + } +} + +class MockBundler extends BundlerClient { + MockBundler() : super(''); + @override + Future> estimateUserOpGas( + Map userOp, String entryPoint) async { + return { + 'callGasLimit': '0x1', + 'verificationGasLimit': '0x1', + 'preVerificationGas': '0x1', + }; + } + + @override + Future sendUserOperation( + Map userOp, String entryPoint) async { + return '0xdead'; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + FlutterSecureStorage.setMockInitialValues({}); + + test('reuses hybrid signature when userOpHash unchanged', () async { + final rpc = MockRpc(); + final bundler = MockBundler(); + final store = PendingIndexStore(); + final keys = deriveFromMnemonic(null); + final cfg = { + 'chainId': 1, + 'walletAddress': '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'entryPoint': '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }; + final flow = UserOpFlow(rpc: rpc, bundler: bundler, store: store); + final to = + EthereumAddress.fromHex('0xcccccccccccccccccccccccccccccccccccccccc'); + final logs = []; + await flow.sendEth( + cfg: cfg, + keys: keys, + to: to, + amountWei: BigInt.one, + settings: const AppSettings(), + log: logs.add, + selectFees: (f) async => f, + ); + final first = logs.last; + logs.clear(); + await flow.sendEth( + cfg: cfg, + keys: keys, + to: to, + amountWei: BigInt.one, + settings: const AppSettings(), + log: logs.add, + selectFees: (f) async => f, + ); + final second = logs.last; + expect(first.contains('decision: fresh'), isTrue); + expect(second.contains('decision: reuse'), isTrue); + }); +} diff --git a/mobile/test/wots_commit_test.dart b/mobile/test/wots_commit_test.dart new file mode 100644 index 0000000..2c28097 --- /dev/null +++ b/mobile/test/wots_commit_test.dart @@ -0,0 +1,45 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pqc_wallet/crypto/wots.dart'; + +void main() { + test('commitPk matches Solidity reference', () { + final pk = List.generate(67, (i) => Uint8List(32)..[31] = i); + final commit = Wots.commitPk(pk); + final expected = Uint8List.fromList([ + 0x76, + 0x5d, + 0x90, + 0xc3, + 0xc6, + 0x81, + 0x03, + 0x59, + 0x23, + 0xf5, + 0xdf, + 0x77, + 0x60, + 0xce, + 0xde, + 0xa6, + 0x8e, + 0xbd, + 0x2d, + 0x97, + 0x7f, + 0xc2, + 0x2a, + 0x37, + 0x52, + 0x83, + 0x91, + 0x04, + 0xc6, + 0xb3, + 0x31, + 0x76, + ]); + expect(commit, expected); + }); +} diff --git a/mobile/test/wots_test.dart b/mobile/test/wots_test.dart index 70220d1..7ee1f3f 100644 --- a/mobile/test/wots_test.dart +++ b/mobile/test/wots_test.dart @@ -5,7 +5,8 @@ import 'package:pqc_wallet/crypto/wots.dart'; void main() { test('WOTS sign-commit-verify consistency', () { - final msg = Uint8List.fromList(sha256.convert(Uint8List.fromList([1,2,3])).bytes); + final msg = + Uint8List.fromList(sha256.convert(Uint8List.fromList([1, 2, 3])).bytes); final seed = Uint8List.fromList(List.filled(32, 7)); final (sk, pk) = Wots.keygen(seed); final sig = Wots.sign(msg, sk); diff --git a/smart-contracts/.gas-snapshot b/smart-contracts/.gas-snapshot index abfbce4..09d89b6 100644 --- a/smart-contracts/.gas-snapshot +++ b/smart-contracts/.gas-snapshot @@ -1,5 +1,17 @@ -PQCWalletTest:test_batch() (gas: 2942144) -PQCWalletTest:test_gas_execute() (gas: 43624) -PQCWalletTest:test_gas_executeBatch() (gas: 47187) -PQCWalletTest:test_gas_validateUserOp() (gas: 2895059) -PQCWalletTest:test_validate_execute() (gas: 2922536) \ No newline at end of file +PQCWalletHybridSigTest:test_Hybrid4417_BytesLayoutAndLength_OKGate() (gas: 615815) +PQCWalletHybridSigTest:test_RevertsOnLegacyLength_NoCommits_4353() (gas: 594122) +PQCWalletHybridSigTest:test_RevertsOnLegacyLength_OneCommit_4385() (gas: 598535) +PQCWalletTest:test_batch() (gas: 2303746) +PQCWalletTest:test_confirm_mismatch_reverts() (gas: 2225235) +PQCWalletTest:test_deposit_and_balanceOfEntryPoint() (gas: 54843) +PQCWalletTest:test_gas_execute() (gas: 43735) +PQCWalletTest:test_gas_executeBatch() (gas: 47231) +PQCWalletTest:test_gas_validateUserOp() (gas: 2921463) +PQCWalletTest:test_getAggregator_default_zero() (gas: 11980) +PQCWalletTest:test_nonce_mismatch_reverts_and_nonce_unchanged() (gas: 2247226) +PQCWalletTest:test_reverts_on_bad_ecdsa_even_with_bad_wots() (gas: 965330) +PQCWalletTest:test_reverts_on_bad_ecdsa_with_valid_wots() (gas: 1700985) +PQCWalletTest:test_setters_and_getAggregator() (gas: 55126) +PQCWalletTest:test_setters_only_owner() (gas: 12445) +PQCWalletTest:test_validate_execute() (gas: 2293103) +WOTSCommitTest:testCommitPkMatchesReference() (gas: 21625) \ No newline at end of file diff --git a/smart-contracts/abi/PQCWallet.json b/smart-contracts/abi/PQCWallet.json index e8cb3d8..1e4b977 100644 --- a/smart-contracts/abi/PQCWallet.json +++ b/smart-contracts/abi/PQCWallet.json @@ -29,6 +29,32 @@ "type": "receive", "stateMutability": "payable" }, + { + "type": "function", + "name": "aggregator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "balanceOfEntryPoint", + "inputs": [], + "outputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "currentPkCommit", @@ -108,6 +134,32 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "forceOnChainVerify", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAggregator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "nextPkCommit", @@ -147,6 +199,32 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "setAggregator", + "inputs": [ + { + "name": "_aggregator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setForceOnChainVerify", + "inputs": [ + { + "name": "enabled", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setNextPkCommit", @@ -160,6 +238,19 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "setVerifier", + "inputs": [ + { + "name": "_verifier", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "validateUserOp", @@ -246,6 +337,32 @@ ], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "verifier", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "AggregatorUpdated", + "inputs": [ + { + "name": "aggregator", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "Executed", @@ -284,6 +401,32 @@ ], "anonymous": false }, + { + "type": "event", + "name": "ForceOnChainSet", + "inputs": [ + { + "name": "enabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "VerifierUpdated", + "inputs": [ + { + "name": "verifier", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "WOTSCommitmentsUpdated", @@ -302,5 +445,35 @@ } ], "anonymous": false + }, + { + "type": "error", + "name": "ECDSA_Invalid", + "inputs": [] + }, + { + "type": "error", + "name": "NextCommit_ConfirmMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "Nonce_Invalid", + "inputs": [] + }, + { + "type": "error", + "name": "NotOwner", + "inputs": [] + }, + { + "type": "error", + "name": "PQC_CommitMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "Sig_Length", + "inputs": [] } ] diff --git a/smart-contracts/contracts/PQCWallet.sol b/smart-contracts/contracts/PQCWallet.sol index a33250a..22069fe 100644 --- a/smart-contracts/contracts/PQCWallet.sol +++ b/smart-contracts/contracts/PQCWallet.sol @@ -10,22 +10,68 @@ import {WOTS} from "./libs/WOTS.sol"; contract PQCWallet { using WOTS for bytes32; + error ECDSA_Invalid(); + error PQC_CommitMismatch(); + error NextCommit_ConfirmMismatch(); + error Nonce_Invalid(); + error NotOwner(); + error Sig_Length(); + + /// @notice Canonical ERC-4337 EntryPoint used by this wallet. IEntryPoint public immutable entryPoint; + + /// @notice ECDSA owner controlling the wallet. address public immutable owner; - bytes32 public currentPkCommit; // commit of current WOTS pk - bytes32 public nextPkCommit; // optional pre-staged next commit (owner can set) + /// @notice Commitment to the current WOTS public key. + bytes32 public currentPkCommit; + + /// @notice Optional pre-staged commitment for the next WOTS key. + bytes32 public nextPkCommit; + + /// @notice ERC-4337 nonce; also the WOTS index source. + uint256 public nonce; + + /// @notice Aggregator contract used for off-chain validation when enabled. + address public aggregator; + + /// @notice Verifier contract validating aggregated signatures. + address public verifier; - uint256 public nonce; // AA nonce; mirrors WOTS index + /// @notice Enforces on-chain WOTS verification when true. + bool public forceOnChainVerify = true; uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; uint256 private _status = _NOT_ENTERED; + /// @notice Emitted when WOTS commitments are rotated or staged. + /// @param currentCommit Commitment to the current WOTS public key. + /// @param nextCommit Commitment to the next WOTS public key. event WOTSCommitmentsUpdated(bytes32 currentCommit, bytes32 nextCommit); + + /// @notice Emitted after a single call is executed. + /// @param target Destination contract for the call. + /// @param value ETH value forwarded with the call. + /// @param data Calldata forwarded. event Executed(address target, uint256 value, bytes data); + + /// @notice Emitted after a batch of calls is executed. + /// @param calls Number of calls executed. event ExecutedBatch(uint256 calls); + /// @notice Emitted when the aggregator is updated. + /// @param aggregator Address of the new aggregator. + event AggregatorUpdated(address indexed aggregator); + + /// @notice Emitted when the verifier contract is updated. + /// @param verifier Address of the new verifier contract. + event VerifierUpdated(address indexed verifier); + + /// @notice Emitted when on-chain verification requirement changes. + /// @param enabled Whether on-chain verification is now enforced. + event ForceOnChainSet(bool enabled); + modifier onlyEntryPoint() { require(msg.sender == address(entryPoint), "not entrypoint"); _; @@ -45,6 +91,12 @@ contract PQCWallet { emit WOTSCommitmentsUpdated(_initialPkCommit, _nextPkCommit); } + /// @notice Return the aggregator if on-chain verify is disabled. + /// @return Aggregator address or zero when forceOnChainVerify is enabled. + function getAggregator() external view returns (address) { + return forceOnChainVerify ? address(0) : aggregator; + } + /// @dev 4417-byte signature packing (exact, no placeholders): /// abi.encodePacked( /// ecdsaSig[65], @@ -64,7 +116,7 @@ contract PQCWallet { uint256 /*missingAccountFunds*/ ) external onlyEntryPoint returns (uint256 validationData) { bytes calldata sig = userOp.signature; - require(sig.length == 4417, "sig length"); + if (sig.length != 4417) revert Sig_Length(); bytes memory ecdsaSig = new bytes(65); bytes32[67] memory wotsSig; @@ -87,24 +139,23 @@ contract PQCWallet { // ECDSA verification address recovered = _recover(userOpHash, ecdsaSig); - require(recovered == owner, "bad ECDSA"); + if (recovered != owner) revert ECDSA_Invalid(); // WOTS commitment check bytes32 computedCommit = WOTS.commitPK(wotsPk); - require(computedCommit == currentPkCommit, "WOTS pk mismatch"); + if (computedCommit != currentPkCommit) revert PQC_CommitMismatch(); // WOTS verification require(WOTS.verify(userOpHash, wotsSig, wotsPk), "bad WOTS"); // One-time rotation - require(confirmNextCommit == nextPkCommit, "confirm mismatch"); - require(proposeNextCommit != bytes32(0), "propose commit required"); - currentPkCommit = confirmNextCommit; + if (confirmNextCommit != nextPkCommit) revert NextCommit_ConfirmMismatch(); + currentPkCommit = nextPkCommit; nextPkCommit = proposeNextCommit; emit WOTSCommitmentsUpdated(currentPkCommit, nextPkCommit); // Nonce - require(userOp.nonce == nonce, "bad nonce"); + if (userOp.nonce != nonce) revert Nonce_Invalid(); nonce++; return 0; // valid @@ -144,11 +195,35 @@ contract PQCWallet { /// @notice Owner convenience method to pre-stage the next WOTS commitment. /// @param nextCommit Commitment to the next WOTS public key. function setNextPkCommit(bytes32 nextCommit) external { - require(msg.sender == owner, "not owner"); + if (msg.sender != owner) revert NotOwner(); nextPkCommit = nextCommit; emit WOTSCommitmentsUpdated(currentPkCommit, nextPkCommit); } + /// @notice Set the aggregator contract used for off-chain validation. + /// @param _aggregator Address of the aggregator. + function setAggregator(address _aggregator) external { + if (msg.sender != owner) revert NotOwner(); + aggregator = _aggregator; + emit AggregatorUpdated(_aggregator); + } + + /// @notice Set the verifier contract for aggregated signatures. + /// @param _verifier Address of the verifier contract. + function setVerifier(address _verifier) external { + if (msg.sender != owner) revert NotOwner(); + verifier = _verifier; + emit VerifierUpdated(_verifier); + } + + /// @notice Enable or disable mandatory on-chain WOTS verification. + /// @param enabled Whether to force on-chain verification. + function setForceOnChainVerify(bool enabled) external { + if (msg.sender != owner) revert NotOwner(); + forceOnChainVerify = enabled; + emit ForceOnChainSet(enabled); + } + /// @notice Receive plain ETH transfers. receive() external payable {} @@ -157,9 +232,15 @@ contract PQCWallet { entryPoint.depositTo{value: msg.value}(address(this)); } + /// @notice Get this wallet's deposit in the EntryPoint. + /// @return amount The current deposit balance held by the EntryPoint. + function balanceOfEntryPoint() external view returns (uint256 amount) { + amount = entryPoint.balanceOf(address(this)); + } + // --------- internal helpers ---------- function _recover(bytes32 digest, bytes memory sig) internal pure returns (address) { - require(sig.length == 65, "ecdsa len"); + if (sig.length != 65) revert Sig_Length(); bytes32 r; bytes32 s; uint8 v; @@ -170,7 +251,7 @@ contract PQCWallet { v := byte(0, mload(add(sig, 0x60))) } if (v < 27) v += 27; - require(v == 27 || v == 28, "bad v"); + if (v != 27 && v != 28) revert ECDSA_Invalid(); return ecrecover(digest, v, r, s); } diff --git a/smart-contracts/contracts/libs/WOTS.sol b/smart-contracts/contracts/libs/WOTS.sol index 54357f1..d21ba9c 100644 --- a/smart-contracts/contracts/libs/WOTS.sol +++ b/smart-contracts/contracts/libs/WOTS.sol @@ -5,23 +5,25 @@ pragma solidity ^0.8.24; /// @notice PQ-conservative, EVM-friendly (sha256). Used for Track A1. /// Public key = 67 * 32B; Signature = 67 * 32B; Message digest = 32B. library WOTS { - uint256 internal constant N = 32; // bytes per element - uint256 internal constant W = 16; // winternitz parameter - uint256 internal constant L1 = 64; // 256 bits / log2(16) - uint256 internal constant L2 = 3; // ceil(log_16(960)) = 3 - uint256 constant L = L1 + L2; + uint256 internal constant N = 32; // bytes per element + uint256 internal constant W = 16; // winternitz parameter + uint256 internal constant L1 = 64; // 256 bits / log2(16) + uint256 internal constant L2 = 3; // ceil(log_16(960)) = 3 + uint256 constant L = L1 + L2; function messageDigits(bytes32 msgHash) internal pure returns (uint8[L] memory digits) { for (uint256 i = 0; i < 32; i++) { uint8 b = uint8(msgHash[i]); - digits[2*i] = b >> 4; - digits[2*i+1] = b & 0x0f; + digits[2 * i] = b >> 4; + digits[2 * i + 1] = b & 0x0f; } uint256 csum = 0; - for (uint256 i = 0; i < L1; i++) csum += (W - 1) - digits[i]; - digits[L1] = uint8((csum >> 8) & 0x0f); - digits[L1+1] = uint8((csum >> 4) & 0x0f); - digits[L1+2] = uint8( csum & 0x0f); + for (uint256 i = 0; i < L1; i++) { + csum += (W - 1) - digits[i]; + } + digits[L1] = uint8((csum >> 8) & 0x0f); + digits[L1 + 1] = uint8((csum >> 4) & 0x0f); + digits[L1 + 2] = uint8(csum & 0x0f); return digits; } @@ -42,8 +44,12 @@ library WOTS { return true; } + /// @notice Hashes a full WOTS public key to a single commitment. + /// @dev Concatenates all 67 elements then applies SHA-256. + /// @param pk The WOTS public key array. + /// @return Commitment as a bytes32 digest. function commitPK(bytes32[L] memory pk) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(pk)); + return sha256(abi.encodePacked(pk)); } // ---------- Deterministic helpers for local tests/demos ---------- @@ -52,7 +58,9 @@ library WOTS { for (uint256 i = 0; i < L; i++) { sk[i] = keccak256(abi.encodePacked(seed, uint32(i))); bytes32 v = sk[i]; - for (uint256 j = 0; j < W - 1; j++) v = _F(v); + for (uint256 j = 0; j < W - 1; j++) { + v = _F(v); + } pk[i] = v; } } @@ -63,7 +71,9 @@ library WOTS { for (uint256 i = 0; i < L; i++) { uint256 steps = d[i]; bytes32 v = sk[i]; - for (uint256 j = 0; j < steps; j++) v = _F(v); + for (uint256 j = 0; j < steps; j++) { + v = _F(v); + } sig[i] = v; } } diff --git a/smart-contracts/test/PQCWallet.t.sol b/smart-contracts/test/PQCWallet.t.sol index f27b4f2..b92540f 100644 --- a/smart-contracts/test/PQCWallet.t.sol +++ b/smart-contracts/test/PQCWallet.t.sol @@ -7,17 +7,23 @@ import {PQCWallet} from "../contracts/PQCWallet.sol"; import {WOTS} from "../contracts/libs/WOTS.sol"; contract DummyEntryPoint is IEntryPoint { + mapping(address => uint256) public balances; + function getUserOpHash(UserOperation calldata userOp) external pure returns (bytes32) { return keccak256(abi.encode(userOp.sender, userOp.nonce, keccak256(userOp.callData))); } - function depositTo(address) external payable {} + function depositTo(address account) external payable { + balances[account] += msg.value; + } - function balanceOf(address) external pure returns (uint256) { - return 0; + function balanceOf(address account) external view returns (uint256) { + return balances[account]; } - function withdrawTo(address payable, uint256) external pure {} + function withdrawTo(address payable, uint256 amount) external { + balances[msg.sender] -= amount; + } } contract Target { @@ -31,12 +37,19 @@ contract Target { contract PQCWalletTest is Test { using WOTS for bytes32; + event WOTSCommitmentsUpdated(bytes32 currentCommit, bytes32 nextCommit); + event AggregatorUpdated(address indexed aggregator); + event VerifierUpdated(address indexed verifier); + event ForceOnChainSet(bool enabled); + DummyEntryPoint ep; PQCWallet wallet; Target target; address owner; uint256 ownerPk; + bytes32[67] sk; + bytes32[67] pk; function setUp() public { ep = new DummyEntryPoint(); @@ -44,12 +57,20 @@ contract PQCWalletTest is Test { (owner, ownerPk) = makeAddrAndKey("owner"); bytes32 seed = keccak256("seed"); - (, bytes32[67] memory pk) = WOTS.keygen(seed); + (sk, pk) = WOTS.keygen(seed); bytes32 commit = WOTS.commitPK(pk); wallet = new PQCWallet(IEntryPoint(address(ep)), owner, commit, keccak256("confirm")); } + function test_deposit_and_balanceOfEntryPoint() public { + assertEq(wallet.balanceOfEntryPoint(), 0); + vm.deal(address(this), 1 ether); + wallet.depositToEntryPoint{value: 1 ether}(); + assertEq(wallet.balanceOfEntryPoint(), 1 ether); + assertEq(ep.balanceOf(address(wallet)), 1 ether); + } + function _packSig( bytes memory ecdsaSig, bytes32[67] memory wotsSig, @@ -68,6 +89,49 @@ contract PQCWalletTest is Test { out = bytes.concat(out, proposeNext); } + function test_getAggregator_default_zero() public { + assertTrue(wallet.forceOnChainVerify()); + assertEq(wallet.getAggregator(), address(0)); + } + + function test_setters_and_getAggregator() public { + address agg = address(0x1234); + address ver = address(0x5678); + + vm.prank(owner); + vm.expectEmit(true, false, false, true, address(wallet)); + emit AggregatorUpdated(agg); + wallet.setAggregator(agg); + assertEq(wallet.aggregator(), agg); + + vm.prank(owner); + vm.expectEmit(true, false, false, true, address(wallet)); + emit VerifierUpdated(ver); + wallet.setVerifier(ver); + assertEq(wallet.verifier(), ver); + + vm.prank(owner); + vm.expectEmit(false, false, false, true, address(wallet)); + emit ForceOnChainSet(false); + wallet.setForceOnChainVerify(false); + assertFalse(wallet.forceOnChainVerify()); + assertEq(wallet.getAggregator(), agg); + } + + function test_setters_only_owner() public { + vm.prank(address(0xdead)); + vm.expectRevert(PQCWallet.NotOwner.selector); + wallet.setAggregator(address(1)); + + vm.prank(address(0xdead)); + vm.expectRevert(PQCWallet.NotOwner.selector); + wallet.setVerifier(address(2)); + + vm.prank(address(0xdead)); + vm.expectRevert(PQCWallet.NotOwner.selector); + wallet.setForceOnChainVerify(false); + } + function test_validate_execute() public { // Build op: setX(42) IEntryPoint.UserOperation memory op; @@ -84,16 +148,20 @@ contract PQCWalletTest is Test { bytes memory eSig = abi.encodePacked(r, s, v); // WOTS - bytes32 seed = keccak256("seed"); - (bytes32[67] memory sk, bytes32[67] memory pk) = WOTS.keygen(seed); bytes32[67] memory sig = WOTS.sign(userOpHash, sk); bytes32 confirmNext = keccak256("confirm"); bytes32 proposeNext = keccak256("next"); op.signature = _packSig(eSig, sig, pk, confirmNext, proposeNext); + vm.expectEmit(false, false, false, true, address(wallet)); + emit WOTSCommitmentsUpdated(confirmNext, proposeNext); vm.prank(address(ep)); wallet.validateUserOp(op, userOpHash, 0); + assertEq(wallet.currentPkCommit(), confirmNext); + assertEq(wallet.nextPkCommit(), proposeNext); + assertEq(wallet.nonce(), 1); + vm.prank(address(ep)); wallet.execute(address(target), 0, abi.encodeWithSelector(Target.setX.selector, 42)); @@ -101,6 +169,104 @@ contract PQCWalletTest is Test { assertEq(wallet.nonce(), 1); } + function test_nonce_mismatch_reverts_and_nonce_unchanged() public { + IEntryPoint.UserOperation memory op; + op.sender = address(wallet); + op.nonce = wallet.nonce() + 1; + op.callData = abi.encodeWithSelector( + PQCWallet.execute.selector, address(target), 0, abi.encodeWithSelector(Target.setX.selector, 42) + ); + + bytes32 userOpHash = ep.getUserOpHash(op); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, userOpHash); + bytes memory eSig = abi.encodePacked(r, s, v); + + bytes32[67] memory sig = WOTS.sign(userOpHash, sk); + bytes32 confirmNext = keccak256("confirm"); + bytes32 proposeNext = keccak256("next"); + op.signature = _packSig(eSig, sig, pk, confirmNext, proposeNext); + + vm.prank(address(ep)); + vm.expectRevert(PQCWallet.Nonce_Invalid.selector); + wallet.validateUserOp(op, userOpHash, 0); + + assertEq(wallet.nonce(), 0); + } + + function test_confirm_mismatch_reverts() public { + IEntryPoint.UserOperation memory op; + op.sender = address(wallet); + op.nonce = wallet.nonce(); + op.callData = abi.encodeWithSelector( + PQCWallet.execute.selector, address(target), 0, abi.encodeWithSelector(Target.setX.selector, 42) + ); + + bytes32 userOpHash = ep.getUserOpHash(op); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, userOpHash); + bytes memory eSig = abi.encodePacked(r, s, v); + + bytes32[67] memory sig = WOTS.sign(userOpHash, sk); + + bytes32 confirmNext = keccak256("wrong"); + bytes32 proposeNext = keccak256("next"); + op.signature = _packSig(eSig, sig, pk, confirmNext, proposeNext); + + vm.prank(address(ep)); + vm.expectRevert(PQCWallet.NextCommit_ConfirmMismatch.selector); + wallet.validateUserOp(op, userOpHash, 0); + } + + function test_reverts_on_bad_ecdsa_with_valid_wots() public { + IEntryPoint.UserOperation memory op; + op.sender = address(wallet); + op.nonce = wallet.nonce(); + op.callData = abi.encodeWithSelector( + PQCWallet.execute.selector, address(target), 0, abi.encodeWithSelector(Target.setX.selector, 42) + ); + + bytes32 userOpHash = ep.getUserOpHash(op); + + (address other, uint256 otherPk) = makeAddrAndKey("other"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(otherPk, userOpHash); + bytes memory eSig = abi.encodePacked(r, s, v); + + bytes32[67] memory sig = WOTS.sign(userOpHash, sk); + bytes32 confirmNext = keccak256("confirm"); + bytes32 proposeNext = keccak256("next"); + op.signature = _packSig(eSig, sig, pk, confirmNext, proposeNext); + + vm.prank(address(ep)); + vm.expectRevert(PQCWallet.ECDSA_Invalid.selector); + wallet.validateUserOp(op, userOpHash, 0); + } + + function test_reverts_on_bad_ecdsa_even_with_bad_wots() public { + IEntryPoint.UserOperation memory op; + op.sender = address(wallet); + op.nonce = wallet.nonce(); + op.callData = abi.encodeWithSelector( + PQCWallet.execute.selector, address(target), 0, abi.encodeWithSelector(Target.setX.selector, 42) + ); + + bytes32 userOpHash = ep.getUserOpHash(op); + + (address other, uint256 otherPk) = makeAddrAndKey("other2"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(otherPk, userOpHash); + bytes memory eSig = abi.encodePacked(r, s, v); + + bytes32[67] memory badSig; + bytes32[67] memory badPk; + bytes32 confirmNext = keccak256("confirm"); + bytes32 proposeNext = keccak256("next"); + op.signature = _packSig(eSig, badSig, badPk, confirmNext, proposeNext); + + vm.prank(address(ep)); + vm.expectRevert(PQCWallet.ECDSA_Invalid.selector); + wallet.validateUserOp(op, userOpHash, 0); + } + function test_batch() public { // two calls in a batch: setX(1) then setX(2) IEntryPoint.UserOperation memory op; @@ -122,8 +288,6 @@ contract PQCWalletTest is Test { (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, userOpHash); bytes memory eSig = abi.encodePacked(r, s, v); - bytes32 seed = keccak256("seed"); - (bytes32[67] memory sk, bytes32[67] memory pk) = WOTS.keygen(seed); bytes32[67] memory sig = WOTS.sign(userOpHash, sk); bytes32 confirmNext = keccak256("confirm"); diff --git a/smart-contracts/test/PQCWalletHybridSig.t.sol b/smart-contracts/test/PQCWalletHybridSig.t.sol index 0e01dec..b6f81f1 100644 --- a/smart-contracts/test/PQCWalletHybridSig.t.sol +++ b/smart-contracts/test/PQCWalletHybridSig.t.sol @@ -68,7 +68,7 @@ contract PQCWalletHybridSigTest is Test { bytes memory legacy = _mkBytes(LEGACY_LEN_NO_COMMITS, 0x11); IEntryPoint.UserOperation memory op = _mkUserOp(legacy); vm.prank(address(ep)); - vm.expectRevert(bytes("sig length")); + vm.expectRevert(PQCWallet.Sig_Length.selector); wallet.validateUserOp(op, bytes32(0), 0); } @@ -76,7 +76,7 @@ contract PQCWalletHybridSigTest is Test { bytes memory legacy = _mkBytes(LEGACY_LEN_ONE_COMMIT, 0x22); IEntryPoint.UserOperation memory op = _mkUserOp(legacy); vm.prank(address(ep)); - vm.expectRevert(bytes("sig length")); + vm.expectRevert(PQCWallet.Sig_Length.selector); wallet.validateUserOp(op, bytes32(0), 0); } diff --git a/smart-contracts/test/WOTSCommit.t.sol b/smart-contracts/test/WOTSCommit.t.sol new file mode 100644 index 0000000..802f563 --- /dev/null +++ b/smart-contracts/test/WOTSCommit.t.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../contracts/libs/WOTS.sol"; + +contract WOTSCommitTest is Test { + function testCommitPkMatchesReference() public { + bytes32[67] memory pk; + for (uint256 i = 0; i < 67; i++) { + pk[i] = bytes32(uint256(i)); + } + bytes32 expected = 0x765d90c3c681035923f5df7760cedea68ebd2d977fc22a3752839104c6b33176; + bytes32 commit = WOTS.commitPK(pk); + assertEq(commit, expected); + } +}