From 7396417133c5770e6b657ce539e338adef20abf7 Mon Sep 17 00:00:00 2001 From: Rastislav Date: Wed, 16 Sep 2026 09:22:44 +0200 Subject: [PATCH] update --- CHANGELOG.md | 8 + README.md | 12 + doc/OPERATIONS.md | 131 ++++ doc/WALLET_VALIDATION.md | 47 ++ example/lib/operations.dart | 113 +++ example/test/operations_test.dart | 108 +++ lib/flutter_connect.dart | 4 + lib/src/operation_channel.dart | 131 ++++ lib/src/operations.dart | 608 ++++++++++++++++ lib/src/wallet_validation.dart | 178 +++++ pubspec.yaml | 3 +- test/fixtures/operations/conformance.json | 831 ++++++++++++++++++++++ test/fixtures/wallet-validation.json | 497 +++++++++++++ test/operations_test.dart | 639 +++++++++++++++++ test/wallet_validation_test.dart | 106 +++ 15 files changed, 3415 insertions(+), 1 deletion(-) create mode 100644 doc/OPERATIONS.md create mode 100644 doc/WALLET_VALIDATION.md create mode 100644 example/lib/operations.dart create mode 100644 example/test/operations_test.dart create mode 100644 lib/src/operation_channel.dart create mode 100644 lib/src/operations.dart create mode 100644 lib/src/wallet_validation.dart create mode 100644 test/fixtures/operations/conformance.json create mode 100644 test/fixtures/wallet-validation.json create mode 100644 test/operations_test.dart create mode 100644 test/wallet_validation_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7be2f..8465bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.3 + +- Add optional chain-aware wallet address validation and shared checksum/network fixtures. + +- Add interoperable multi-chain Wallet Operations models, capabilities, adapters, explicit approval, dispatch, safe errors and encrypted transport codecs. +- Add shared protocol/encryption fixtures, regression coverage and host integration examples. +- Retain existing authentication protocol and APIs. + ## 0.1.2 - Align publishing with the Flutter reference libraries: validate on tag push, then publish through Dart's reusable pub.dev workflow. diff --git a/README.md b/README.md index 123eb5c..1bca26d 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,15 @@ Shared vectors assert identical Dart/TypeScript messages. Dart Ed25519 signature See [release setup](doc/RELEASE.md). Licensed under the [CORE License](LICENSE). This package is distributed under the [CORE License](LICENSE). It is not an OSI-approved license. + +# Wallet Operations + +Connect transports signing requests; the host wallet owns and uses the private key. + +`ConnectOperation`, `OperationResponse`, `OperationCapability` and `OperationDispatcher` implement the canonical connect-protocol operation format. Chain adapters validate native payloads and declare capabilities. Wallet/custom operations require explicit host approval; read-only chain operations use separate handlers. Existing account providers supply identities without exposing keys or invoking authentication signers. + +See the [operation protocol and API guide](doc/OPERATIONS.md) and [EVM, Bitcoin PSBT, Solana, Core and balance examples](example/lib/operations.dart). `OperationTransport` handles host messaging, and `OperationCipher` optionally encrypts requests/responses. Existing authentication, links and handoffs remain unchanged. + +## Wallet address validation + +`validateWalletAccount` checks addresses against an explicit Connect namespace/reference and returns `valid`, `invalid`, or `unsupported`. `isValidWalletAccount` accepts only `valid`. These checks supplement wallet ownership verification. See [coverage, adapter integration and package compatibility](doc/WALLET_VALIDATION.md). diff --git a/doc/OPERATIONS.md b/doc/OPERATIONS.md new file mode 100644 index 0000000..1e386c9 --- /dev/null +++ b/doc/OPERATIONS.md @@ -0,0 +1,131 @@ +# Wallet Operations + +Connect transports signing requests; the host wallet owns and uses the private key. + +`connect-protocol` defines the canonical wire format; `flutter_connect` implements the same format. Authentication remains a separate, unchanged Connect v1 exchange. The operation APIs are additive in package version 0.1.3. + +```text +DApp +| +v +Connect Protocol +| ++---- Wallet Operations ----> Wallet ----> Signer +| ++---- Chain Operations -----> RPC/Node ----> Blockchain +| ++---- Capability Negotiation +| ++---- Chain Adapter +``` + +## Wire format + +Operations reuse Connect's 64-character lowercase hexadecimal `requestId` and `{namespace, reference, address}` account identity. An account, when present, must match the envelope chain. `createOperation` (TypeScript) and `ConnectOperation` (Dart) generate cryptographically random IDs unless supplied by the caller. + +```json +{ + "version": 1, + "requestId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "operation": "wallet.signTransaction", + "chain": { "namespace": "eip155", "reference": "1" }, + "account": { "namespace": "eip155", "reference": "1", "address": "0x1111111111111111111111111111111111111111" }, + "params": { "transaction": { "to": "0x2222222222222222222222222222222222222222", "value": "0x1" } }, + "metadata": { "application": "Example dapp" } +} +``` + +Success: + +```json +{"version":1,"requestId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","result":{"signedTransaction":"0x..."}} +``` + +Rejection: + +```json +{"version":1,"requestId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","error":{"code":"USER_REJECTED","message":"User rejected the operation."}} +``` + +`result` can be any JSON value, including null. Exactly one of `result` and `error` is required. Error `details` is an optional JSON object. Dispatchers emit fixed safe messages and omit details; transport parsers accept safe host-defined details. A malformed request without a valid ID receives `requestId: null`; all other responses retain the supplied valid ID. Unknown envelope versions yield `INVALID_PARAMS`. `parseOperationResponse` / `OperationResponse.fromJson` can enforce an expected ID; `requestOperation` always enforces it. + +Requests, responses and capability lists are limited to 16,384 UTF-8 bytes and nesting depth 32. Unknown envelope fields, non-JSON values, unsafe integer numbers, and common secret field names (`privateKey`, `secretKey`, `mnemonic`, `seedPhrase`, including underscore/hyphen variants) are rejected. Use decimal or hex **strings** for balances, fees and large integers; represent bytes explicitly as base64/hex strings. These checks do not detect arbitrary secrets inside opaque strings: host handlers must return only public results. Never put keys, seeds, PINs or credentials into payloads or metadata. + +## Operations and capabilities + +| Methods | Behavior | +| --- | --- | +| `wallet.getAccounts` | Explicitly approved identity disclosure; `operationAccounts` reuses existing account providers without invoking their authentication signers. | +| `wallet.signMessage` | Host message signing; adapter may prepare a message for the approval callback. | +| `wallet.signTransaction`, `wallet.sendTransaction` | Native chain-specific transaction payload; host signs or signs/broadcasts. | +| `wallet.switchChain`, `wallet.addChain` | Host wallet network policy/configuration. The envelope identifies the requested target chain. | +| `wallet.signTypedData`, `wallet.signPsbt`, `wallet.signAllTransactions` | Optional specialized capabilities. Never inferred from namespace. | +| `chain.getBalance`, `chain.getTransaction`, `chain.getBlock`, `chain.estimateFee`, `chain.call` | Read-only host RPC/node handlers, without wallet approval or signer access. | + +`OperationDispatcher.capabilities` is an immutable advertisement suitable for the host's authenticated session setup. Serialize it as a JSON array; Dart capabilities expose `toJson()`. `parseCapabilities` validates advertisements and rejects duplicate chain entries or methods. `supportsOperation` and `dispatcher.supports` query support by both namespace and reference. + +```json +[ + {"namespace":"eip155","reference":"1","methods":["wallet.signTransaction","chain.getBalance"]}, + {"namespace":"bip122","reference":"000000000019d6689c085ae165831e93","methods":["wallet.signPsbt"]} +] +``` + +Only capabilities with registered handlers can be advertised. Different networks can expose different methods. Capability advertisement is not authorization: the wallet still approves each wallet/custom request. There is no implicit chain switching or automatic RPC configuration. + +## Chain adapters + +`OperationChainAdapter` declares capabilities, handlers, `validateAccount` and `validateOperation`. Validation must check all chain-specific transaction/message fields, network bindings and encodings before approval. An invalid address returns false; validation can throw `OperationError('INVALID_TRANSACTION')` / `OperationException('INVALID_TRANSACTION')`. Optional hooks provide message preparation, result normalization and safe error-code translation. Preparation must not sign or obtain keys. Handlers receive the same immutable validated request that approval receives. The prepared message is additional approval context; a signing handler can use the same deterministic preparation routine. + +Use existing `chains` / `ConnectChains` presets for namespace/reference values. TypeScript chain definitions structurally satisfy `OperationChain`; Dart uses `OperationChain.fromChain`. Register separate capabilities for distinct references; duplicate adapter registrations for the same chain are rejected. + +The extension interface supports all existing chain presets: Core, Ethereum, Base, Polygon, BNB Smart Chain, Bitcoin, Litecoin, Solana, TRON, XRP Ledger, Stellar, Cardano, Monero and Zcash. It does **not** provide native transaction parsers, signers, broadcasters or RPC clients for these chains. Those belong to the host's SDKs. An authentication signing profile does not imply any transaction capability. For example, advertise Bitcoin PSBT only if the wallet actually supports it; Litecoin/Zcash do not acquire PSBT support from sharing `bip122`. + +There is no universal transaction schema. Optional TypeScript `EvmTransactionParams`, `PsbtParams`, `SolanaTransactionParams` and `CoreTransactionParams` describe outer native-payload containers only, not full SDK validation. A Bitcoin PSBT remains a PSBT; a Solana serialized transaction remains Solana data; Core uses its own transaction fields such as energy. + +## Approval, accounts and dispatch + +Create a dispatcher per authenticated host session with adapters, an existing `AccountProvider` / `WalletAccountProvider`, and an explicit `approve` callback. Dispatch with an origin obtained from the trusted host transport. HTTPS origin syntax is validated but does not authenticate the sender. Request `metadata` is an untrusted application claim; never derive the trusted origin from it. + +The approval callback receives the full chain, account, params, metadata, trusted origin and cancellation context. Decode amount, destination, fee and network with the host chain SDK and show them with the requesting application before approval. The protocol contains no UI. All `wallet.*` and custom methods require approval; omission returns `UNAUTHORIZED`, false returns `USER_REJECTED`. Read-only `chain.*` handlers bypass approval. Do not register a signing implementation under a read-only chain method. + +Signing/sending methods require an account, a successful adapter address check and membership in the existing host account provider. Account availability is checked again after approval. Authentication account `sign()` callbacks are never reused to sign arbitrary transactions. Existing account identity/profile representations remain unchanged. + +Dispatch flow: parse/snapshot → bind origin and consume ID → resolve chain → check capability → check account → validate payload → prepare/approve wallet request → recheck account → execute host handler → normalize public result → validate/serialize correlated response. + +A session consumes each valid ID once, including rejected/failed requests. It holds at most 4,096 IDs by default and refuses further requests rather than evicting replay protection. Host sessions must manage authorization lifetime and persist replay protection if requests must survive process restarts. Create new IDs for genuinely new operations; don't blindly retry a send after a timeout. + +Default execution timeout is 30 seconds, configurable through `timeoutMs` / `timeout`. TypeScript handlers receive an `AbortSignal`; Dart handlers can check `context.isCancelled`. Late approval cannot start a handler after timeout. A host operation already in progress cannot be rolled back by Connect; the host must honor cancellation where possible and reconcile uncertain broadcasts. Creating a new dispatcher resets in-memory replay state. + +## Custom operations + +Register `myapp.customOperation` in both an adapter's methods and handlers. Names match `[a-z][a-z0-9]*\.[a-zA-Z][a-zA-Z0-9]*` and are at most 128 characters. `wallet.*` and `chain.*` are reserved: only the listed standard methods can be registered there. Custom operations require explicit approval and adapter validation. Unregistered names return `UNSUPPORTED_OPERATION`. + +## Transport and encryption + +`OperationTransport.exchange` sends and receives JSON strings; `requestOperation` validates and correlates responses. A host can implement HTTPS, extension messaging, QR/manual return, Bluetooth or any future message channel. `dispatcher.dispatchJson` is the wallet endpoint. The protocol performs no network I/O or UI by itself. + +`OperationCipher(origin, requestId, pairingKey)` optionally wraps requests and responses using the existing Connect AES-256-GCM convention: 32-byte pairing key, fresh random 12-byte nonce, ciphertext followed by 16-byte authentication tag, unpadded base64url. Keys are supplied through a trusted pairing channel and are never wallet signing keys. The API keeps them private internally. Request and response directions use distinct authenticated data: + +```text +connect:operation:1/{request|response}/{https-origin}/{requestId} +``` + +Encrypted packets have exactly `type: "connect:operation:encrypted:1"`, `kind`, `requestId`, `iv`, `ciphertext`; maximum packet size is 32,768 characters. This new type cannot be confused with authentication's `connect:response:1`. `sealRequest`/`openRequest` and `sealResponse`/`openResponse` enforce direction and correlation. Existing Dart `ConnectBleResponseBuffer` can frame these strings without change. QR and deep-link hosts carry the same packet using their own routing and trusted pairing setup; existing authentication links are not operation links. Encryption provides channel confidentiality/integrity, not wallet ownership verification. Do not log pairing material. + +## Errors + +Stable codes: `UNSUPPORTED_OPERATION`, `UNSUPPORTED_CHAIN`, `UNSUPPORTED_ACCOUNT`, `INVALID_PARAMS`, `INVALID_TRANSACTION`, `USER_REJECTED`, `UNAUTHORIZED`, `SIGNING_FAILED`, `BROADCAST_FAILED`, `CHAIN_UNAVAILABLE`, `TIMEOUT`, `INTERNAL_ERROR`. + +Throw a protocol exception with a code, or use the adapter's `translateError` callback. Unrecognized callback exceptions are replaced with a fixed stage-appropriate message; exception contents never enter protocol responses. Approval exceptions become `UNAUTHORIZED`; signing errors become `SIGNING_FAILED`; send errors become `BROADCAST_FAILED`; read-only handler errors become `CHAIN_UNAVAILABLE`. + +## Examples and compatibility + +The operation examples demonstrate EVM signing, Bitcoin PSBT signing, Solana serialized transaction signing, Core transaction signing and read-only balances through explicit host callbacks. They require real SDK validation and host signing; fixture transactions are serialization examples, not broadcastable transactions. + +- TypeScript/JavaScript: `examples/operations.mjs`, exported `createExampleOperations` and `exampleRequests`. +- Flutter/Dart: `example/lib/operations.dart`, `OperationsHost`, `createExampleOperations` and `exampleRequests`. + +Shared `test/fixtures/operations/conformance.json` covers chain identities, all methods, custom methods, responses, capabilities and deterministic encrypted interoperability. Both implementations parse the same fixtures. Live cross-language tests also exchange freshly encrypted packets. Existing authentication and handoff tests remain regression coverage; no authentication messages, URI formats, signers, or verification profiles were changed. + +Run `npm run test:operations-interop` from connect.js with flutter_connect alongside it (or run `node scripts/test-operation-interop.mjs /path/to/flutter_connect` after the build). The command compares shared fixtures and verifies freshly encrypted requests and responses in both languages. Normal validation remains `npm test`, `npm run typecheck`, `npm run lint`, `npm run test:browser`, `npm run test:package`, `flutter analyze`, and `flutter test` in both the Flutter library and example. diff --git a/doc/WALLET_VALIDATION.md b/doc/WALLET_VALIDATION.md new file mode 100644 index 0000000..86b64a8 --- /dev/null +++ b/doc/WALLET_VALIDATION.md @@ -0,0 +1,47 @@ +# Wallet address validation + +`validateWalletAccount({namespace, reference, address})` returns a safe `status`: `valid`, `invalid`, or `unsupported`. `isValidWalletAccount` returns true only for `valid`. Dart uses the existing `WalletIdentity` and `WalletValidationStatus` enum. The helpers never rewrite the account identity or expose upstream exception messages. + +The integrations pin `blockchain-wallet-validator` 1.2.1 and `flutter_wallet_validator` 0.1.4. Explicit namespace/reference mapping prevents automatic network detection or ENS/domain acceptance. Address validity does not prove ownership, account availability, selected network, or transaction safety. Existing signature verification and host approval remain required. + +## Operation adapters + +Wrap a host adapter with `withWalletValidation(adapter)` in either library. A known-invalid address is rejected before host approval or handler execution. The existing host account validator still runs for valid addresses and for unsupported chains/formats; it remains responsible for handling those extensions. The operation examples enable this wrapper. + +```ts +const dispatcher = new OperationDispatcher({ + adapters: [withWalletValidation(myChainAdapter)], + accounts: walletAccounts, + approve: showApproval, +}); +``` + +For transaction destinations, call `validateWalletAccount` with the request's chain and the decoded destination address from inside `validateOperation`. Reject `invalid`; use the chain SDK for `unsupported`. Connect does not guess transaction field names or impose a universal transaction format. Resolve name-service identifiers separately before creating an account identity. + +## Coverage and limitations + +| Namespace/reference | Address checks | +| --- | --- | +| `core:1`, `core:3` | ICAN checksum and exact mainnet/Devin prefix, case-insensitive validation without rewriting identity | +| `eip155:` | 20-byte hex format; mixed-case EIP-55 checksum; unchecksummed lowercase/uppercase bodies accepted | +| Bitcoin/Litecoin existing mainnet `bip122` presets | Base58Check version/length; Bech32/Bech32m witness version, program length, checksum and mainnet HRP | +| Existing Solana mainnet preset | Base58 decoding to exactly 32 bytes, without unrelated-chain prefix heuristics | +| Existing TRON and XRP presets | Address version, decoded length and checksum | +| `stellar:pubnet` | Public-account StrKey version and CRC checksum | +| Existing Cardano mainnet preset | Shelley base, enterprise and reward addresses; Bech32 checksum, header, length and network tag | + +EVM addresses do not encode a chain ID. Solana, XRP and other shared address formats likewise cannot prove which network the wallet selected. Chain capabilities, session policy and the signer must enforce that binding. + +Monero, Zcash, unknown chain references and Cardano pointer forms require existing host/profile validation. They are never reported as validated by this helper. Byron and other unrecognized address forms are not accepted by the mapped Cardano preset; applications needing them must supply a dedicated adapter without this wrapper. No additional testnets are inferred from an address or a boolean testnet option. + +## Package compatibility checks + +The TypeScript package's generic result is insufficient for every supported format: legacy Base58Check addresses, SegWit checksums and Cardano enterprise addresses need supplemental codec checks. The Flutter package also has restrictive Cardano patterns and Solana prefix heuristics. The wrappers use the packages for available checks and supplement those gaps with explicit decoding. They never turn a failed checksum into an accepted address based on a regex. + +`test/fixtures/wallet-validation.json` is identical across connect.js, flutter_connect and better-connect. Its 55 cases cover existing account fixtures, corrupted checksums, wrong networks, EVM casing, Core Devin, Taproot, Cardano headers, unsupported chains and valid Solana keys beginning with `r` or `T`. Each repository runs these cases locally; operation/server tests additionally verify rejection before approval or proof verification. + +## better-connect + +The server applies these checks automatically in `ConnectEngine.approve`, after envelope/profile compatibility checks and before profile verification. Known-invalid accounts return `unsupportedAccount` and leave the request pending. Unsupported chains continue through the configured signing profile's validator and cryptographic proof verification. A valid address never bypasses proof verification. + +better-connect exports the same `validateWalletAccount` and `isValidWalletAccount` helpers. Its adapter source and fixtures mirror connect.js while its existing immutable connect-protocol SDK pin remains unchanged; it does not depend on unpublished workspace code. diff --git a/example/lib/operations.dart b/example/lib/operations.dart new file mode 100644 index 0000000..5097d4e --- /dev/null +++ b/example/lib/operations.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'package:flutter_connect/flutter_connect.dart'; + +/// Implement these callbacks with the wallet's SDKs and approval UI. +/// validateOperation must validate the complete native payload for the chain. +abstract interface class OperationsHost { + WalletAccountProvider get accounts; + FutureOr approve( + ConnectOperation request, + OperationContext context, + Object? preparedMessage, + ); + FutureOr validateAccount(WalletIdentity account); + FutureOr validateOperation(ConnectOperation request); + FutureOr signEvm(ConnectOperation request, OperationContext context); + FutureOr signPsbt( + ConnectOperation request, + OperationContext context, + ); + FutureOr signSolana( + ConnectOperation request, + OperationContext context, + ); + FutureOr signCore( + ConnectOperation request, + OperationContext context, + ); + FutureOr getBalance( + ConnectOperation request, + OperationContext context, + ); +} + +OperationDispatcher createExampleOperations(OperationsHost host) { + final definitions = <(ConnectChain, String, OperationHandler)>[ + (ConnectChains.ethereum, OperationMethods.signTransaction, host.signEvm), + (ConnectChains.bitcoin, OperationMethods.signPsbt, host.signPsbt), + (ConnectChains.solana, OperationMethods.signTransaction, host.signSolana), + (ConnectChains.core, OperationMethods.signTransaction, host.signCore), + ]; + return OperationDispatcher( + accounts: host.accounts, + approve: host.approve, + adapters: definitions.map((entry) { + final chain = OperationChain.fromChain(entry.$1); + return withWalletValidation( + OperationChainAdapter( + capabilities: [ + OperationCapability(chain.namespace, chain.reference, [ + entry.$2, + OperationMethods.getAccounts, + OperationMethods.getBalance, + ]), + ], + validateAccount: host.validateAccount, + validateOperation: host.validateOperation, + handlers: { + entry.$2: entry.$3, + OperationMethods.getAccounts: operationAccounts( + host.accounts, + chain, + ), + OperationMethods.getBalance: host.getBalance, + }, + ), + ); + }), + ); +} + +/// Supply real native transactions; these payload formats are deliberately different. +List exampleRequests({ + required WalletIdentity evm, + required WalletIdentity bitcoin, + required WalletIdentity solana, + required WalletIdentity core, + required Map evmTransaction, + required String psbt, + required String solanaTransaction, + required Map coreTransaction, +}) { + ConnectOperation make( + String operation, + WalletIdentity account, + Map params, + ) => ConnectOperation( + operation: operation, + chain: OperationChain(account.namespace, account.reference), + account: account, + params: params, + metadata: {'application': 'Example dapp'}, + ); + return [ + make(OperationMethods.signTransaction, evm, { + 'transaction': evmTransaction, + }), + make(OperationMethods.signPsbt, bitcoin, { + 'psbt': psbt, + 'encoding': 'base64', + }), + make(OperationMethods.signTransaction, solana, { + 'transaction': solanaTransaction, + 'encoding': 'base64', + }), + make(OperationMethods.signTransaction, core, { + 'transaction': coreTransaction, + }), + make(OperationMethods.getBalance, evm, {'address': evm.address}), + ]; +} +// Dapp: requestOperation(transport, request). +// Wallet: dispatcher.dispatchJson(receivedJson, origin: authenticatedTransportOrigin). +// Encrypted channels wrap dispatch with OperationCipher.openRequest / sealResponse. diff --git a/example/test/operations_test.dart b/example/test/operations_test.dart new file mode 100644 index 0000000..c3ef993 --- /dev/null +++ b/example/test/operations_test.dart @@ -0,0 +1,108 @@ +import 'dart:typed_data'; +import 'package:connect_wallet_example/operations.dart'; +import 'package:flutter_connect/flutter_connect.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class ExampleAccount implements WalletAccount { + @override + final SigningSelection selection; + ExampleAccount(ConnectChain chain) + : selection = chain.account( + { + 'core': 'cb23f5e89ba978317da861b7ad06ba3103d41c876098', + 'eip155': '0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1', + 'bip122': 'bc1q0xcqpzrky6eff2g52qdye53xkk9jxkvrh6yhyw', + 'solana': 'AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9', + }[chain.namespace]!, + ); + @override + Future sign(Uint8List payload) async => + throw StateError('Authentication signer must not run'); +} + +class ExampleHost implements OperationsHost, WalletAccountProvider { + int approvals = 0, signatures = 0, reads = 0; + final identities = [ + ConnectChains.ethereum, + ConnectChains.bitcoin, + ConnectChains.solana, + ConnectChains.core, + ].map(ExampleAccount.new).toList(); + @override + WalletAccountProvider get accounts => this; + @override + Future> getAccounts() async => identities; + @override + bool approve( + ConnectOperation request, + OperationContext context, + Object? preparedMessage, + ) { + approvals++; + return true; + } + + @override + bool validateAccount(WalletIdentity account) => true; + @override + void validateOperation(ConnectOperation request) { + // Test host only. Real hosts must use chain SDK validation before approval. + expect(request.params, isNotEmpty); + } + + Object sign(ConnectOperation request) { + signatures++; + return {'signed': 'example-only'}; + } + + @override + Object signEvm(ConnectOperation request, OperationContext context) => + sign(request); + @override + Object signPsbt(ConnectOperation request, OperationContext context) => + sign(request); + @override + Object signSolana(ConnectOperation request, OperationContext context) => + sign(request); + @override + Object signCore(ConnectOperation request, OperationContext context) => + sign(request); + @override + Object getBalance(ConnectOperation request, OperationContext context) { + reads++; + return '100'; + } +} + +void main() { + test( + 'native payload examples sign four requests and read balance independently', + () async { + final host = ExampleHost(); + final dispatcher = createExampleOperations(host); + final a = host.identities.map((a) => a.selection.account).toList(); + final requests = exampleRequests( + evm: a[0], + bitcoin: a[1], + solana: a[2], + core: a[3], + evmTransaction: {'value': '0x1'}, + psbt: 'cHNidP8=', + solanaTransaction: 'AQID', + coreTransaction: {'energy': '21000'}, + ); + for (final request in requests) { + expect( + (await dispatcher.dispatch( + request, + origin: 'https://example.com', + )).isSuccess, + isTrue, + ); + } + expect(host.approvals, 4); + expect(host.signatures, 4); + expect(host.reads, 1); + }, + ); +} diff --git a/lib/flutter_connect.dart b/lib/flutter_connect.dart index 741ca73..9369029 100644 --- a/lib/flutter_connect.dart +++ b/lib/flutter_connect.dart @@ -12,3 +12,7 @@ export 'src/chains.dart'; export 'src/handoff.dart'; export 'src/bluetooth.dart'; + +export 'src/operations.dart'; +export 'src/operation_channel.dart'; +export 'src/wallet_validation.dart'; diff --git a/lib/src/operation_channel.dart b/lib/src/operation_channel.dart new file mode 100644 index 0000000..dbed692 --- /dev/null +++ b/lib/src/operation_channel.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; +import 'operations.dart'; +import 'protocol.dart'; + +/// Pairing encryption follows the existing Connect AES-GCM convention. +/// The host supplies a session pairing key, never a wallet signing key. +/// Encryption does not establish wallet ownership. Never log pairing keys. +class OperationCipher { + final String _origin, _requestId; + final SecretKey _key; + OperationCipher._(this._origin, this._requestId, this._key); + factory OperationCipher( + String origin, + String requestId, + List pairingKey, + ) { + try { + validateOrigin(origin); + identifier(requestId); + if (pairingKey.length != 32 || pairingKey.any((v) => v < 0 || v > 255)) { + throw StateError('invalid'); + } + return OperationCipher._( + origin, + requestId, + SecretKey(List.of(pairingKey)), + ); + } catch (_) { + throw OperationException('INVALID_PARAMS'); + } + } + Future sealRequest(ConnectOperation request) { + if (request.requestId != _requestId) { + throw OperationException('INVALID_PARAMS'); + } + return _seal(request.serialize(), 'request'); + } + + Future sealResponse(OperationResponse response) { + if (response.requestId != _requestId) { + throw OperationException('INVALID_PARAMS'); + } + return _seal(response.serialize(), 'response'); + } + + Future openRequest(String packet) async { + final request = ConnectOperation.fromJson(await _open(packet, 'request')); + if (request.requestId != _requestId) { + throw OperationException('INVALID_PARAMS'); + } + return request; + } + + Future openResponse(String packet) async => + OperationResponse.fromJson( + await _open(packet, 'response'), + expectedRequestId: _requestId, + ); + List _aad(String kind) => + utf8.encode('connect:operation:1/$kind/$_origin/$_requestId'); + String _encode(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); + List _decode(Object? value) { + if (value is! String || !matches(r'[A-Za-z0-9_-]+', value)) { + throw OperationException('INVALID_PARAMS'); + } + final bytes = base64Url.decode(base64Url.normalize(value)); + if (_encode(bytes) != value) throw OperationException('INVALID_PARAMS'); + return bytes; + } + + Future _seal(String plaintext, String kind) async { + try { + final bytes = utf8.encode(plaintext); + if (bytes.length > maxOperationBytes) { + throw OperationException('INVALID_PARAMS'); + } + final box = await AesGcm.with256bits().encrypt( + bytes, + secretKey: _key, + aad: _aad(kind), + ); + return jsonEncode({ + 'type': 'connect:operation:encrypted:1', + 'kind': kind, + 'requestId': _requestId, + 'iv': _encode(box.nonce), + 'ciphertext': _encode([...box.cipherText, ...box.mac.bytes]), + }); + } catch (_) { + throw OperationException('INVALID_PARAMS'); + } + } + + Future _open(String input, String kind) async { + try { + if (input.length > 32768) throw OperationException('INVALID_PARAMS'); + final p = object(jsonDecode(input), [ + 'type', + 'kind', + 'requestId', + 'iv', + 'ciphertext', + ]); + if (p['type'] != 'connect:operation:encrypted:1' || + p['kind'] != kind || + p['requestId'] != _requestId) { + throw OperationException('INVALID_PARAMS'); + } + final iv = _decode(p['iv']), bytes = _decode(p['ciphertext']); + if (iv.length != 12 || + bytes.length < 16 || + bytes.length > maxOperationBytes + 16) { + throw OperationException('INVALID_PARAMS'); + } + final plaintext = await AesGcm.with256bits().decrypt( + SecretBox( + bytes.sublist(0, bytes.length - 16), + nonce: iv, + mac: Mac(bytes.sublist(bytes.length - 16)), + ), + secretKey: _key, + aad: _aad(kind), + ); + return utf8.decode(plaintext); + } catch (_) { + throw OperationException('INVALID_PARAMS'); + } + } +} diff --git a/lib/src/operations.dart b/lib/src/operations.dart new file mode 100644 index 0000000..89701ad --- /dev/null +++ b/lib/src/operations.dart @@ -0,0 +1,608 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'protocol.dart'; +import 'wallet.dart'; +import 'chains.dart'; + +const maxOperationBytes = 16384; + +abstract final class OperationMethods { + static const getAccounts = 'wallet.getAccounts'; + static const signMessage = 'wallet.signMessage'; + static const signTransaction = 'wallet.signTransaction'; + static const sendTransaction = 'wallet.sendTransaction'; + static const switchChain = 'wallet.switchChain'; + static const addChain = 'wallet.addChain'; + static const signTypedData = 'wallet.signTypedData'; + static const signPsbt = 'wallet.signPsbt'; + static const signAllTransactions = 'wallet.signAllTransactions'; + static const getBalance = 'chain.getBalance'; + static const getTransaction = 'chain.getTransaction'; + static const getBlock = 'chain.getBlock'; + static const estimateFee = 'chain.estimateFee'; + static const call = 'chain.call'; + static const all = { + getAccounts, + signMessage, + signTransaction, + sendTransaction, + switchChain, + addChain, + signTypedData, + signPsbt, + signAllTransactions, + getBalance, + getTransaction, + getBlock, + estimateFee, + call, + }; +} + +const operationErrorMessages = { + 'UNSUPPORTED_OPERATION': 'Operation is not supported.', + 'UNSUPPORTED_CHAIN': 'Chain is not supported.', + 'UNSUPPORTED_ACCOUNT': 'Account is not available.', + 'INVALID_PARAMS': 'Invalid operation parameters.', + 'INVALID_TRANSACTION': 'Invalid transaction.', + 'USER_REJECTED': 'User rejected the operation.', + 'UNAUTHORIZED': 'Operation is not authorized.', + 'SIGNING_FAILED': 'Wallet signing failed.', + 'BROADCAST_FAILED': 'Transaction broadcast failed.', + 'CHAIN_UNAVAILABLE': 'Chain is unavailable.', + 'TIMEOUT': 'Operation timed out.', + 'INTERNAL_ERROR': 'Operation failed.', +}; + +class OperationException implements Exception { + final String code; + OperationException(String code) + : code = operationErrorMessages.containsKey(code) ? code : 'INTERNAL_ERROR'; + String get message => operationErrorMessages[code]!; + @override + String toString() => 'OperationException($code)'; +} + +Never _fail([String code = 'INVALID_PARAMS']) => throw OperationException(code); +Object? _snapshot(Object? value, [int depth = 0]) { + if (depth > 32) _fail(); + if (value == null || value is String || value is bool) return value; + if (value is num && + value.isFinite && + (value % 1 != 0 || value.abs() <= 9007199254740991)) { + return value; + } + if (value is List) { + return List.unmodifiable( + value.map((v) => _snapshot(v, depth + 1)), + ); + } + if (value is! Map) _fail(); + final result = {}; + for (final entry in value.entries) { + if (entry.key is! String) _fail(); + final key = entry.key as String; + if (RegExp( + r'^(?:proto|prototype|constructor|privatekey|secretkey|mnemonic|seedphrase)$', + caseSensitive: false, + ).hasMatch(key.replaceAll(RegExp('[_-]'), ''))) { + _fail(); + } + result[key] = _snapshot(entry.value, depth + 1); + } + return Map.unmodifiable(result); +} + +Object? _input(Object? input) { + try { + if (input is String) { + if (utf8.encode(input).length > maxOperationBytes) _fail(); + input = jsonDecode(input); + } + final result = _snapshot(input); + if (utf8.encode(jsonEncode(result)).length > maxOperationBytes) _fail(); + return result; + } catch (_) { + _fail(); + } +} + +Map _map(Object? value) { + if (value is! Map) _fail(); + return value; +} + +void _keys( + Map j, + List required, [ + List optional = const [], +]) { + if (!required.every(j.containsKey) || + j.keys.any((k) => !required.contains(k) && !optional.contains(k))) { + _fail(); + } +} + +String _atom(Object? value) { + try { + return atom(value); + } catch (_) { + _fail(); + } +} + +String _id(Object? value) { + try { + return identifier(value); + } catch (_) { + _fail(); + } +} + +String _method(Object? value, {bool registration = false}) { + if (value is! String || + value.length > 128 || + !matches(r'[a-z][a-z0-9]*\.[a-zA-Z][a-zA-Z0-9]*', value)) { + _fail(); + } + if (registration && + (value.startsWith('wallet.') || value.startsWith('chain.')) && + !OperationMethods.all.contains(value)) { + _fail('UNSUPPORTED_OPERATION'); + } + return value; +} + +class OperationChain { + final String namespace, reference; + OperationChain(String namespace, String reference) + : namespace = _atom(namespace), + reference = _atom(reference); + factory OperationChain.fromChain(ConnectChain chain) => + OperationChain(chain.namespace, chain.reference); + factory OperationChain.fromJson(Object? value) { + final j = _map(value); + _keys(j, ['namespace', 'reference']); + return OperationChain(_atom(j['namespace']), _atom(j['reference'])); + } + Map toJson() => { + 'namespace': namespace, + 'reference': reference, + }; + String get key => '$namespace:$reference'; +} + +class ConnectOperation { + final String requestId, operation; + final OperationChain chain; + final WalletIdentity? account; + final Map params; + final Map? metadata; + int get version => 1; + ConnectOperation._( + this.requestId, + this.operation, + this.chain, + this.account, + this.params, + this.metadata, + ); + factory ConnectOperation({ + String? requestId, + required String operation, + required OperationChain chain, + WalletIdentity? account, + required Map params, + Map? metadata, + }) { + final random = Random.secure(); + return ConnectOperation.fromJson({ + 'version': 1, + 'requestId': + requestId ?? + List.generate( + 32, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(), + 'operation': operation, + 'chain': chain.toJson(), + if (account != null) 'account': account.toJson(), + 'params': params, + 'metadata': ?metadata, + }); + } + factory ConnectOperation.fromJson(Object? input) { + try { + final j = _map(_input(input)); + _keys( + j, + ['version', 'requestId', 'operation', 'chain', 'params'], + ['account', 'metadata'], + ); + if (j['version'] != 1) _fail(); + final chain = OperationChain.fromJson(j['chain']); + final account = j.containsKey('account') + ? WalletIdentity.fromJson(j['account']) + : null; + if (account != null && + (account.namespace != chain.namespace || + account.reference != chain.reference)) { + _fail('UNSUPPORTED_ACCOUNT'); + } + return ConnectOperation._( + _id(j['requestId']), + _method(j['operation']), + chain, + account, + _map(j['params']), + j.containsKey('metadata') ? _map(j['metadata']) : null, + ); + } on OperationException { + rethrow; + } catch (_) { + _fail(); + } + } + Map toJson() => { + 'version': 1, + 'requestId': requestId, + 'operation': operation, + 'chain': chain.toJson(), + if (account != null) 'account': account!.toJson(), + 'params': params, + if (metadata != null) 'metadata': metadata, + }; + String serialize() => jsonEncode(toJson()); +} + +class OperationFailure { + final String code, message; + final Map? details; + OperationFailure._(this.code, this.message, this.details); + factory OperationFailure.fromJson(Object? value) { + final j = _map(value); + _keys(j, ['code', 'message'], ['details']); + if (!operationErrorMessages.containsKey(j['code']) || + j['message'] is! String || + (j['message'] as String).isEmpty || + (j['message'] as String).length > 256) { + _fail(); + } + return OperationFailure._( + j['code'] as String, + j['message'] as String, + j.containsKey('details') ? _map(_snapshot(j['details'])) : null, + ); + } + Map toJson() => { + 'code': code, + 'message': message, + if (details != null) 'details': details, + }; +} + +class OperationResponse { + final String? requestId; + final Object? result; + final OperationFailure? error; + bool get isSuccess => error == null; + OperationResponse._(this.requestId, this.result, this.error); + factory OperationResponse.success(String requestId, Object? result) => + OperationResponse.fromJson({ + 'version': 1, + 'requestId': requestId, + 'result': result, + }); + factory OperationResponse.failure(String? requestId, String code) => + OperationResponse.fromJson({ + 'version': 1, + 'requestId': requestId, + 'error': {'code': code, 'message': operationErrorMessages[code]}, + }); + factory OperationResponse.fromJson( + Object? input, { + String? expectedRequestId, + }) { + final j = _map(_input(input)); + final failure = j.containsKey('error'); + _keys(j, ['version', 'requestId', failure ? 'error' : 'result']); + if (j['version'] != 1) _fail(); + final id = failure && j['requestId'] == null ? null : _id(j['requestId']); + if (expectedRequestId != null && id != _id(expectedRequestId)) _fail(); + return OperationResponse._( + id, + failure ? null : j['result'], + failure ? OperationFailure.fromJson(j['error']) : null, + ); + } + Map toJson() => { + 'version': 1, + 'requestId': requestId, + if (error != null) 'error': error!.toJson() else 'result': result, + }; + String serialize() => jsonEncode(toJson()); +} + +class OperationCapability extends OperationChain { + final List methods; + OperationCapability( + super.namespace, + super.reference, + Iterable methods, + ) : methods = List.unmodifiable( + methods.map((m) => _method(m, registration: true)), + ) { + if (this.methods.length > 128 || + this.methods.toSet().length != this.methods.length) { + _fail(); + } + } + factory OperationCapability.fromJson(Object? value) { + final j = _map(value); + _keys(j, ['namespace', 'reference', 'methods']); + if (j['methods'] is! List) _fail(); + return OperationCapability( + _atom(j['namespace']), + _atom(j['reference']), + (j['methods'] as List).map((m) => _method(m, registration: true)), + ); + } + @override + Map toJson() => {...super.toJson(), 'methods': methods}; +} + +List parseCapabilities(Object? input) { + final value = _input(input); + if (value is! List || value.length > 128) _fail(); + final result = value.map(OperationCapability.fromJson).toList(); + if (result.map((c) => c.key).toSet().length != result.length) _fail(); + return List.unmodifiable(result); +} + +bool supportsOperation( + Iterable capabilities, + OperationChain chain, + String method, +) => capabilities.any((c) => c.key == chain.key && c.methods.contains(method)); + +class OperationContext { + /// Trusted transport origin, never request metadata. + final String origin; + bool _cancelled = false; + bool get isCancelled => _cancelled; + OperationContext._(this.origin); +} + +typedef OperationHandler = + FutureOr Function( + ConnectOperation request, + OperationContext context, + ); +typedef OperationApproval = + FutureOr Function( + ConnectOperation request, + OperationContext context, + Object? preparedMessage, + ); + +/// A host-owned adapter; validators must inspect the complete chain-specific payload. +class OperationChainAdapter { + final List capabilities; + final Map handlers; + final FutureOr Function(WalletIdentity) validateAccount; + final FutureOr Function(ConnectOperation) validateOperation; + final FutureOr Function(ConnectOperation)? prepareMessage; + final Object? Function(ConnectOperation, Object?)? normalizeResult; + final String? Function(Object)? translateError; + OperationChainAdapter({ + required Iterable capabilities, + required Map handlers, + required this.validateAccount, + required this.validateOperation, + this.prepareMessage, + this.normalizeResult, + this.translateError, + }) : capabilities = parseCapabilities( + capabilities.map((c) => c.toJson()).toList(), + ), + handlers = Map.unmodifiable(handlers); +} + +class OperationDispatcher { + final Map _adapters = {}; + final Set _handled = {}; + final OperationApproval? approve; + final WalletAccountProvider? accounts; + final Duration timeout; + final int maxRequests; + late final List capabilities; + OperationDispatcher({ + required Iterable adapters, + this.approve, + this.accounts, + this.timeout = const Duration(seconds: 30), + this.maxRequests = 4096, + }) { + if (timeout.inMilliseconds <= 0 || maxRequests <= 0) _fail(); + final caps = []; + for (final adapter in adapters) { + for (final c in adapter.capabilities) { + if (_adapters.containsKey(c.key)) _fail(); + if (c.methods.any((m) => !adapter.handlers.containsKey(m))) { + _fail('UNSUPPORTED_OPERATION'); + } + _adapters[c.key] = adapter; + caps.add(c); + } + } + capabilities = parseCapabilities(caps.map((c) => c.toJson()).toList()); + } + bool supports(OperationChain chain, String method) => + supportsOperation(capabilities, chain, method); + Future dispatch( + Object? input, { + required String origin, + }) async { + ConnectOperation request; + try { + request = ConnectOperation.fromJson( + input is ConnectOperation ? input.toJson() : input, + ); + } catch (e) { + String? id; + try { + id = _id(_map(_input(input))['requestId']); + } catch (_) { + /* Missing identifier. */ + } + return OperationResponse.failure( + id, + e is OperationException ? e.code : 'INVALID_PARAMS', + ); + } + final context = OperationContext._(origin); + try { + return await _execute(request, context).timeout( + timeout, + onTimeout: () { + context._cancelled = true; + return OperationResponse.failure(request.requestId, 'TIMEOUT'); + }, + ); + } finally { + context._cancelled = true; + } + } + + Future dispatchJson(String input, {required String origin}) async => + (await dispatch(input, origin: origin)).serialize(); + Future _execute( + ConnectOperation request, + OperationContext context, + ) async { + OperationChainAdapter? adapter; + var stage = 'INVALID_PARAMS'; + void alive() { + if (context.isCancelled) _fail('TIMEOUT'); + } + + try { + try { + validateOrigin(context.origin); + } catch (_) { + _fail('UNAUTHORIZED'); + } + if (_handled.contains(request.requestId) || + _handled.length >= maxRequests) { + _fail('UNAUTHORIZED'); + } + _handled.add(request.requestId); + adapter = _adapters[request.chain.key]; + if (adapter == null) _fail('UNSUPPORTED_CHAIN'); + if (!supports(request.chain, request.operation)) { + _fail('UNSUPPORTED_OPERATION'); + } + final wallet = request.operation.startsWith('wallet.'); + final signing = RegExp( + r'^wallet\.(sign|send)', + ).hasMatch(request.operation); + if (signing && request.account == null) _fail('UNSUPPORTED_ACCOUNT'); + Future checkAccount() async { + final account = request.account; + if (account == null) return; + if (!await adapter!.validateAccount(account)) { + _fail('UNSUPPORTED_ACCOUNT'); + } + if (wallet) { + if (accounts == null) _fail('UNSUPPORTED_ACCOUNT'); + final available = await accounts!.getAccounts(); + if (!available.any( + (a) => + a.selection.account.namespace == account.namespace && + a.selection.account.reference == account.reference && + a.selection.account.address == account.address, + )) { + _fail('UNSUPPORTED_ACCOUNT'); + } + } + } + + await checkAccount(); + alive(); + await adapter.validateOperation(request); + alive(); + if (!request.operation.startsWith('chain.')) { + stage = 'UNAUTHORIZED'; + if (approve == null) _fail('UNAUTHORIZED'); + final prepared = + request.operation == OperationMethods.signMessage && + adapter.prepareMessage != null + ? _snapshot(await adapter.prepareMessage!(request)) + : null; + alive(); + if (!await approve!(request, context, prepared)) _fail('USER_REJECTED'); + alive(); + await checkAccount(); + alive(); + } + stage = request.operation == OperationMethods.sendTransaction + ? 'BROADCAST_FAILED' + : signing + ? 'SIGNING_FAILED' + : request.operation.startsWith('chain.') + ? 'CHAIN_UNAVAILABLE' + : 'INTERNAL_ERROR'; + final result = await adapter.handlers[request.operation]!( + request, + context, + ); + alive(); + stage = 'INTERNAL_ERROR'; + return OperationResponse.success( + request.requestId, + adapter.normalizeResult != null + ? adapter.normalizeResult!(request, result) + : result, + ); + } catch (e) { + var code = e is OperationException ? e.code : stage; + if (e is! OperationException && adapter?.translateError != null) { + try { + final translated = adapter!.translateError!(e); + if (operationErrorMessages.containsKey(translated)) { + code = translated!; + } + } catch (_) { + /* Safe fallback. */ + } + } + return OperationResponse.failure(request.requestId, code); + } + } +} + +OperationHandler operationAccounts( + WalletAccountProvider provider, + OperationChain chain, +) => + (request, context) async => (await provider.getAccounts()) + .where( + (a) => + a.selection.account.namespace == chain.namespace && + a.selection.account.reference == chain.reference, + ) + .map((a) => a.selection.account.toJson()) + .toList(); + +abstract interface class OperationTransport { + Future exchange(String request); +} + +Future requestOperation( + OperationTransport transport, + ConnectOperation request, +) async => OperationResponse.fromJson( + await transport.exchange(request.serialize()), + expectedRequestId: request.requestId, +); diff --git a/lib/src/wallet_validation.dart b/lib/src/wallet_validation.dart new file mode 100644 index 0000000..8f4c259 --- /dev/null +++ b/lib/src/wallet_validation.dart @@ -0,0 +1,178 @@ +import 'package:flutter_wallet_validator/flutter_wallet_validator.dart' + as validator; +import 'protocol.dart'; +import 'operations.dart'; + +enum WalletValidationStatus { valid, invalid, unsupported } + +class WalletValidationResult { + final WalletValidationStatus status; + const WalletValidationResult(this.status); +} + +const _networks = { + 'core:1': 'xcb', + 'core:3': 'xab', + 'bip122:000000000019d6689c085ae165831e93': 'btc', + 'bip122:12a765e31ffd4059bada1e25190f6e98': 'ltc', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'sol', + 'tron:728126428': 'trx', + 'xrpl:0': 'xrp', + 'stellar:pubnet': 'xlm', + 'cip34:1-764824073': 'ada', +}; +const _invalid = WalletValidationResult(WalletValidationStatus.invalid); +const _unsupported = WalletValidationResult(WalletValidationStatus.unsupported); +WalletValidationResult _valid(bool value) => value + ? const WalletValidationResult(WalletValidationStatus.valid) + : _invalid; +int _base58Length(String value) { + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + var number = BigInt.zero; + for (final c in value.codeUnits) { + final digit = alphabet.indexOf(String.fromCharCode(c)); + if (digit < 0) throw const FormatException(); + number = number * BigInt.from(58) + BigInt.from(digit); + } + return (number.bitLength + 7) ~/ 8 + + value.codeUnits.takeWhile((c) => c == 49).length; +} + +WalletValidationResult _cardano(String address) { + // Bech32 checksum + Shelley header/length. Pointer and Byron forms remain host-owned. + if (address != address.toLowerCase()) return _invalid; + final separator = address.lastIndexOf('1'); + if (separator < 1 || separator + 7 > address.length) return _invalid; + final hrp = address.substring(0, separator); + if (hrp != 'addr' && hrp != 'stake') return _invalid; + const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + final words = address + .substring(separator + 1) + .codeUnits + .map((c) => alphabet.indexOf(String.fromCharCode(c))) + .toList(); + if (words.any((v) => v < 0)) return _invalid; + final values = [ + ...hrp.codeUnits.map((v) => v >> 5), + 0, + ...hrp.codeUnits.map((v) => v & 31), + ...words, + ]; + var check = 1; + const generators = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3, + ]; + for (final value in values) { + final top = check >> 25; + check = ((check & 0x1ffffff) << 5) ^ value; + for (var i = 0; i < 5; i++) { + if (((top >> i) & 1) != 0) check ^= generators[i]; + } + } + if (check != 1) return _invalid; + var acc = 0, bits = 0; + final bytes = []; + for (final word in words.take(words.length - 6)) { + acc = ((acc << 5) | word) & 0xffff; + bits += 5; + while (bits >= 8) { + bits -= 8; + bytes.add((acc >> bits) & 255); + } + } + if (bits >= 5 || ((acc << (8 - bits)) & 255) != 0 || bytes.isEmpty) { + return _invalid; + } + if ((bytes[0] & 15) != 1) return _invalid; + final type = bytes[0] >> 4; + if (type <= 3) return _valid(hrp == 'addr' && bytes.length == 57); + if (type == 6 || type == 7) { + return _valid(hrp == 'addr' && bytes.length == 29); + } + if (type == 14 || type == 15) { + return _valid(hrp == 'stake' && bytes.length == 29); + } + return _unsupported; +} + +/// Address validity only: does not establish wallet ownership or selected network. +/// Unknown references/formats return unsupported and require host validation. +WalletValidationResult validateWalletAccount(WalletIdentity account) { + try { + final namespace = account.namespace, + reference = account.reference, + address = account.address; + if (![ + namespace, + reference, + address, + ].every((v) => v.length <= 128 && matches(r'[a-zA-Z0-9._-]+', v))) { + return _invalid; + } + final network = namespace == 'eip155' + ? 'evm' + : _networks['$namespace:$reference']; + if (network == null) return _unsupported; + if (network == 'evm' && !matches(r'[1-9][0-9]{0,14}', reference)) { + return _invalid; + } + final body = address.length >= 2 ? address.substring(2) : ''; + final packageAddress = + network == 'evm' && + (body == body.toUpperCase() || body == body.toLowerCase()) + ? address.toLowerCase() + : address; + final upstream = validator.validateWalletAddress( + packageAddress, + options: validator.ValidationOptions( + network: [network], + testnet: network == 'xab', + enabledLegacy: true, + nsDomains: const [], + ), + ); + if (network == 'evm') { + return _valid(matches(r'0x[0-9a-fA-F]{40}', address) && upstream.isValid); + } + if (network == 'xcb' || network == 'xab') { + return _valid( + address.toLowerCase().startsWith(network == 'xcb' ? 'cb' : 'ab') && + upstream.isValid, + ); + } + if (network == 'sol') return _valid(_base58Length(address) == 32); + if (network == 'ada') return _cardano(address); + if (network == 'btc' || network == 'ltc') { + final hrp = network == 'btc' ? 'bc' : 'ltc'; + // Only mainnet references are mapped. testnet=true is never used for these. + if (address.toLowerCase().startsWith('${hrp}1') && address.length > 90) { + return _invalid; + } + } + return _valid(upstream.isValid); + } catch (_) { + return _invalid; + } +} + +bool isValidWalletAccount(WalletIdentity account) => + validateWalletAccount(account).status == WalletValidationStatus.valid; + +/// Composes address checks with the existing mandatory host account policy. +OperationChainAdapter withWalletValidation(OperationChainAdapter adapter) => + OperationChainAdapter( + capabilities: adapter.capabilities, + handlers: adapter.handlers, + validateAccount: (account) async => + validateWalletAccount(account).status != + WalletValidationStatus.invalid && + await adapter.validateAccount(account), + validateOperation: adapter.validateOperation, + prepareMessage: adapter.prepareMessage, + normalizeResult: adapter.normalizeResult, + translateError: adapter.translateError, + ); diff --git a/pubspec.yaml b/pubspec.yaml index 2d9a4b1..9efe1cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_connect description: Wallet-side Connect protocol with strict transport validation, explicit approval, and multi-chain signing adapters. -version: 0.1.2 +version: 0.1.3 homepage: https://github.com/bchainhub/flutter_connect repository: https://github.com/bchainhub/flutter_connect issue_tracker: https://github.com/bchainhub/flutter_connect/issues @@ -14,6 +14,7 @@ dependencies: app_links: ^7.2.1 bluetooth_low_energy: ^6.2.1 cryptography: ^2.9.0 + flutter_wallet_validator: 0.1.4 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/fixtures/operations/conformance.json b/test/fixtures/operations/conformance.json new file mode 100644 index 0000000..348ff03 --- /dev/null +++ b/test/fixtures/operations/conformance.json @@ -0,0 +1,831 @@ +{ + "requests": [ + { + "name": "xcb-ed448", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000001", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "transaction": { + "to": "cb23f5e89ba978317da861b7ad06ba3103d41c876098", + "value": "1", + "energy": "21000" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "ethereum-siwe", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000002", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "eip155", + "reference": "1" + }, + "account": { + "namespace": "eip155", + "reference": "1", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "params": { + "transaction": { + "to": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1", + "value": "0x1", + "gas": "0x5208" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "bitcoin-bip322-p2wpkh", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000003", + "operation": "wallet.signPsbt", + "chain": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93" + }, + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "bc1q0xcqpzrky6eff2g52qdye53xkk9jxkvrh6yhyw" + }, + "params": { + "psbt": "cHNidP8=", + "encoding": "base64" + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Polygon", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000004", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "eip155", + "reference": "137" + }, + "account": { + "namespace": "eip155", + "reference": "137", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "params": { + "transaction": { + "to": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1", + "value": "0x1", + "gas": "0x5208" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Base", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000005", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "eip155", + "reference": "8453" + }, + "account": { + "namespace": "eip155", + "reference": "8453", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "params": { + "transaction": { + "to": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1", + "value": "0x1", + "gas": "0x5208" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "BNB Smart Chain", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000006", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "eip155", + "reference": "56" + }, + "account": { + "namespace": "eip155", + "reference": "56", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "params": { + "transaction": { + "to": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1", + "value": "0x1", + "gas": "0x5208" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Solana", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000007", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + }, + "account": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" + }, + "params": { + "transaction": "AQID", + "encoding": "base64" + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "TRON", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000008", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "tron", + "reference": "728126428" + }, + "account": { + "namespace": "tron", + "reference": "728126428", + "address": "TCNkawTmcQgYSU8nP8cHswT1QPjharxJr7" + }, + "params": { + "transaction": { + "chainSpecific": "TRON" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Monero", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000009", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "monero", + "reference": "418015bb9ae982a1975da7d79277c270" + }, + "account": { + "namespace": "monero", + "reference": "418015bb9ae982a1975da7d79277c270", + "address": "44yQXfkWZNmJ8QgRfFWTzmJ8QgRfFWTzmJ8QgRfFWTzmJ9HskYAEKgjVy4kd3K4MaxERLtGa7FFrVNLF3jYWyjHCFA3tNHB" + }, + "params": { + "transaction": { + "chainSpecific": "Monero" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Stellar", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000000a", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "stellar", + "reference": "pubnet" + }, + "account": { + "namespace": "stellar", + "reference": "pubnet", + "address": "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR" + }, + "params": { + "transaction": { + "chainSpecific": "Stellar" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Litecoin", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000000b", + "operation": "wallet.signMessage", + "chain": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98" + }, + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "LWKNsGErA9XxsrKVPimDAbuRXjCyyazZtc" + }, + "params": { + "message": "Connect operations fixture" + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "XRP Ledger", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000000c", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "xrpl", + "reference": "0" + }, + "account": { + "namespace": "xrpl", + "reference": "0", + "address": "rMPrYipfRHJryWfwYARAwhsVGvHwpUDjgA" + }, + "params": { + "transaction": { + "chainSpecific": "XRP Ledger" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Zcash", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000000d", + "operation": "wallet.signMessage", + "chain": { + "namespace": "bip122", + "reference": "00040fe8ec8471911baa1db1266ea15d" + }, + "account": { + "namespace": "bip122", + "reference": "00040fe8ec8471911baa1db1266ea15d", + "address": "t1Uy2cPMA3p5WDggEA1b32PwaaB2nfDssvp" + }, + "params": { + "message": "Connect operations fixture" + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Cardano", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000000e", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "cip34", + "reference": "1-764824073" + }, + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1vyxk54m7j3q6mrkevcunryrwf4p7e68c93cjk8gzxkhlkpsjpczl2" + }, + "params": { + "transaction": { + "chainSpecific": "Cardano" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "Bitcoin (legacy P2PKH)", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000000f", + "operation": "wallet.signMessage", + "chain": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93" + }, + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "1C6Rc3w25VHud3dLDamutaqfKWqhrLRTaD" + }, + "params": { + "message": "Connect operations fixture" + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "XRP Ledger (Ed25519)", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000010", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "xrpl", + "reference": "0" + }, + "account": { + "namespace": "xrpl", + "reference": "0", + "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" + }, + "params": { + "transaction": { + "chainSpecific": "XRP Ledger (Ed25519)" + } + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.getAccounts", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000011", + "operation": "wallet.getAccounts", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.signMessage", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000012", + "operation": "wallet.signMessage", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.signTransaction", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000013", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.sendTransaction", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000014", + "operation": "wallet.sendTransaction", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.switchChain", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000015", + "operation": "wallet.switchChain", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.addChain", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000016", + "operation": "wallet.addChain", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.signTypedData", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000017", + "operation": "wallet.signTypedData", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.signPsbt", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000018", + "operation": "wallet.signPsbt", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "wallet.signAllTransactions", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000019", + "operation": "wallet.signAllTransactions", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "chain.getBalance", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000001a", + "operation": "chain.getBalance", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "chain.getTransaction", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000001b", + "operation": "chain.getTransaction", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "chain.getBlock", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000001c", + "operation": "chain.getBlock", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "chain.estimateFee", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000001d", + "operation": "chain.estimateFee", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "chain.call", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000001e", + "operation": "chain.call", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + }, + { + "name": "myapp.customOperation", + "request": { + "version": 1, + "requestId": "000000000000000000000000000000000000000000000000000000000000001f", + "operation": "myapp.customOperation", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "sample": true + }, + "metadata": { + "application": "Fixture dapp" + } + } + } + ], + "responses": [ + { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000001", + "result": { + "signedTransaction": "fixture-only" + } + }, + { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000001", + "result": null + }, + { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000001", + "error": { + "code": "USER_REJECTED", + "message": "User rejected the operation." + } + } + ], + "capabilities": [ + { + "namespace": "core", + "reference": "1", + "methods": [ + "wallet.getAccounts", + "wallet.signMessage", + "wallet.signTransaction", + "wallet.sendTransaction", + "wallet.switchChain", + "wallet.addChain", + "wallet.signTypedData", + "wallet.signPsbt", + "wallet.signAllTransactions", + "chain.getBalance", + "chain.getTransaction", + "chain.getBlock", + "chain.estimateFee", + "chain.call", + "myapp.customOperation" + ] + } + ], + "encrypted": { + "origin": "https://example.com", + "pairingKeyHex": "0101010101010101010101010101010101010101010101010101010101010101", + "request": { + "version": 1, + "requestId": "0000000000000000000000000000000000000000000000000000000000000001", + "operation": "wallet.signTransaction", + "chain": { + "namespace": "core", + "reference": "1" + }, + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "params": { + "transaction": { + "to": "cb23f5e89ba978317da861b7ad06ba3103d41c876098", + "value": "1", + "energy": "21000" + } + }, + "metadata": { + "application": "Fixture dapp" + } + }, + "packet": { + "type": "connect:operation:encrypted:1", + "kind": "request", + "requestId": "0000000000000000000000000000000000000000000000000000000000000001", + "iv": "AgICAgICAgICAgIC", + "ciphertext": "fPS_LDgkqJK97ob5cIWw19PAtJyf-OlT56XQy0jLRE9HTy4HXV-T33ceYtmvF2xLmkXu55dq7Rb7c4OCUwR9ukTUodKW-i1YQWd_iNAsWBE7R2sRe5gEtbZMNud-L6FxLiQ4LVc4VaLCEigTYL224wF17X9dyI-JMTjj0EijBcNOQWZWvqJtuha7KDNlrsFnkdlEvO7mHhN-QW6knwJw_TrWuFbJsrDTy6YxepdsdXG5IO7lbcTEYin-Ud_2J3t7QxQLSxZV9Ecph0LvytfYhpHhsBfTJMOHK3CpG06EmDCuU8nIfOX7nRUfqi0_3cDYljRc1xdijS1SPWSqNBI_JqncFKBAFsu5-DVtx4tov63lPV7qIB-r4yrpk_o1_MXChxLMOiZClZGAWTyD4Od5bzBOrPSQYdX4LOUfX1iJuOcndXkkhS_yb_ktdIEGj8EwyubIS5PP7EopMG9dbcNpoQSWemaqJ-WnOaA5yk8GuAmGNyqfka4i4DW-AHku6kPAAZCKtxHbnSyp1Ify6U2fdY6ES7ISPu8AkwpL5EEQw5YS3TCQ1Ow8ZPay5kOgV6j5EmHgc9a4KqEwL7-9" + } + } +} diff --git a/test/fixtures/wallet-validation.json b/test/fixtures/wallet-validation.json new file mode 100644 index 0000000..b1e008d --- /dev/null +++ b/test/fixtures/wallet-validation.json @@ -0,0 +1,497 @@ +[ + { + "name": "xcb-ed448", + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876098" + }, + "status": "valid" + }, + { + "name": "raw-ed25519", + "account": { + "namespace": "raw", + "reference": "ed25519", + "address": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c" + }, + "status": "unsupported" + }, + { + "name": "ethereum-siwe", + "account": { + "namespace": "eip155", + "reference": "1", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "status": "valid" + }, + { + "name": "bitcoin-bip322-p2wpkh", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "bc1q0xcqpzrky6eff2g52qdye53xkk9jxkvrh6yhyw" + }, + "status": "valid" + }, + { + "name": "Polygon", + "account": { + "namespace": "eip155", + "reference": "137", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "status": "valid" + }, + { + "name": "Base", + "account": { + "namespace": "eip155", + "reference": "8453", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "status": "valid" + }, + { + "name": "BNB Smart Chain", + "account": { + "namespace": "eip155", + "reference": "56", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "status": "valid" + }, + { + "name": "Solana", + "account": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" + }, + "status": "valid" + }, + { + "name": "TRON", + "account": { + "namespace": "tron", + "reference": "728126428", + "address": "TCNkawTmcQgYSU8nP8cHswT1QPjharxJr7" + }, + "status": "valid" + }, + { + "name": "Monero", + "account": { + "namespace": "monero", + "reference": "418015bb9ae982a1975da7d79277c270", + "address": "44yQXfkWZNmJ8QgRfFWTzmJ8QgRfFWTzmJ8QgRfFWTzmJ9HskYAEKgjVy4kd3K4MaxERLtGa7FFrVNLF3jYWyjHCFA3tNHB" + }, + "status": "unsupported" + }, + { + "name": "Stellar", + "account": { + "namespace": "stellar", + "reference": "pubnet", + "address": "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR" + }, + "status": "valid" + }, + { + "name": "Litecoin", + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "LWKNsGErA9XxsrKVPimDAbuRXjCyyazZtc" + }, + "status": "valid" + }, + { + "name": "XRP Ledger", + "account": { + "namespace": "xrpl", + "reference": "0", + "address": "rMPrYipfRHJryWfwYARAwhsVGvHwpUDjgA" + }, + "status": "valid" + }, + { + "name": "Zcash", + "account": { + "namespace": "bip122", + "reference": "00040fe8ec8471911baa1db1266ea15d", + "address": "t1Uy2cPMA3p5WDggEA1b32PwaaB2nfDssvp" + }, + "status": "unsupported" + }, + { + "name": "Cardano", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1vyxk54m7j3q6mrkevcunryrwf4p7e68c93cjk8gzxkhlkpsjpczl2" + }, + "status": "valid" + }, + { + "name": "Bitcoin (legacy P2PKH)", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "1C6Rc3w25VHud3dLDamutaqfKWqhrLRTaD" + }, + "status": "valid" + }, + { + "name": "XRP Ledger (Ed25519)", + "account": { + "namespace": "xrpl", + "reference": "0", + "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" + }, + "status": "valid" + }, + { + "name": "EVM lowercase", + "account": { + "namespace": "eip155", + "reference": "1", + "address": "0x1a642f0e3c3af545e7acbd38b07251b3990914f1" + }, + "status": "valid" + }, + { + "name": "EVM uppercase body", + "account": { + "namespace": "eip155", + "reference": "1", + "address": "0x1A642F0E3C3AF545E7ACBD38B07251B3990914F1" + }, + "status": "valid" + }, + { + "name": "EVM bad mixed checksum", + "account": { + "namespace": "eip155", + "reference": "1", + "address": "0x1A642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "status": "invalid" + }, + { + "name": "EVM ENS name", + "account": { + "namespace": "eip155", + "reference": "1", + "address": "vitalik.eth" + }, + "status": "invalid" + }, + { + "name": "EVM malformed reference", + "account": { + "namespace": "eip155", + "reference": "01", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" + }, + "status": "invalid" + }, + { + "name": "trailing whitespace", + "account": { + "namespace": "eip155", + "reference": "1", + "address": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1\n" + }, + "status": "invalid" + }, + { + "name": "xcb-ed448 bad checksum", + "account": { + "namespace": "core", + "reference": "1", + "address": "cb23f5e89ba978317da861b7ad06ba3103d41c876091" + }, + "status": "invalid" + }, + { + "name": "bitcoin-bip322-p2wpkh bad checksum", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "bc1q0xcqpzrky6eff2g52qdye53xkk9jxkvrh6yhy1" + }, + "status": "invalid" + }, + { + "name": "TRON bad checksum", + "account": { + "namespace": "tron", + "reference": "728126428", + "address": "TCNkawTmcQgYSU8nP8cHswT1QPjharxJr1" + }, + "status": "invalid" + }, + { + "name": "Stellar bad checksum", + "account": { + "namespace": "stellar", + "reference": "pubnet", + "address": "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJ1" + }, + "status": "invalid" + }, + { + "name": "Litecoin bad checksum", + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "LWKNsGErA9XxsrKVPimDAbuRXjCyyazZt1" + }, + "status": "invalid" + }, + { + "name": "XRP Ledger bad checksum", + "account": { + "namespace": "xrpl", + "reference": "0", + "address": "rMPrYipfRHJryWfwYARAwhsVGvHwpUDjg1" + }, + "status": "invalid" + }, + { + "name": "Cardano bad checksum", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1vyxk54m7j3q6mrkevcunryrwf4p7e68c93cjk8gzxkhlkpsjpczl1" + }, + "status": "invalid" + }, + { + "name": "Bitcoin (legacy P2PKH) bad checksum", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "1C6Rc3w25VHud3dLDamutaqfKWqhrLRTa1" + }, + "status": "invalid" + }, + { + "name": "XRP Ledger (Ed25519) bad checksum", + "account": { + "namespace": "xrpl", + "reference": "0", + "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dT1" + }, + "status": "invalid" + }, + { + "name": "Core Devin", + "account": { + "namespace": "core", + "reference": "3", + "address": "ab29b451c7cd5a1610c8aa18fd9013d0e7dcc4501a31" + }, + "status": "valid" + }, + { + "name": "Core Devin uppercase", + "account": { + "namespace": "core", + "reference": "3", + "address": "AB29B451C7CD5A1610C8AA18FD9013D0E7DCC4501A31" + }, + "status": "valid" + }, + { + "name": "Core wrong network", + "account": { + "namespace": "core", + "reference": "1", + "address": "ab29b451c7cd5a1610c8aa18fd9013d0e7dcc4501a31" + }, + "status": "invalid" + }, + { + "name": "bc Taproot", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "bc1pqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsvk87xn" + }, + "status": "valid" + }, + { + "name": "bc Taproot uppercase", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "BC1PQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSVK87XN" + }, + "status": "valid" + }, + { + "name": "bc wrong witness checksum", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "bc1pqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpse2hjr3" + }, + "status": "invalid" + }, + { + "name": "bc testnet address on mainnet", + "account": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + "address": "tb1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcr7mrzn4" + }, + "status": "invalid" + }, + { + "name": "ltc Taproot", + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "ltc1pqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps0jfwuk" + }, + "status": "valid" + }, + { + "name": "ltc Taproot uppercase", + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "LTC1PQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPS0JFWUK" + }, + "status": "valid" + }, + { + "name": "ltc wrong witness checksum", + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "ltc1pqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps6weze5" + }, + "status": "invalid" + }, + { + "name": "ltc testnet address on mainnet", + "account": { + "namespace": "bip122", + "reference": "12a765e31ffd4059bada1e25190f6e98", + "address": "tltc1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcr8npuru" + }, + "status": "invalid" + }, + { + "name": "Solana short decoded key", + "account": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "address": "1111111111111111111111111111111" + }, + "status": "invalid" + }, + { + "name": "Solana zero key", + "account": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "address": "11111111111111111111111111111111" + }, + "status": "valid" + }, + { + "name": "Solana valid r prefix", + "account": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "address": "r1Uidf5M379kEsQREYFiZgJP9TGi437KHiWuX5M4J9M" + }, + "status": "valid" + }, + { + "name": "Solana valid T prefix", + "account": { + "namespace": "solana", + "reference": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "address": "T1o7YUcLByKaGt1V4MvPxvp88qvEhxDqyb27WcBpwwM" + }, + "status": "valid" + }, + { + "name": "Cardano base", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs9zjxd9" + }, + "status": "valid" + }, + { + "name": "Cardano mismatched network header base", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1qqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs55mplc" + }, + "status": "invalid" + }, + { + "name": "Cardano enterprise", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1vyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgxca7aw" + }, + "status": "valid" + }, + { + "name": "Cardano mismatched network header enterprise", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "addr1vqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgx2d0af" + }, + "status": "invalid" + }, + { + "name": "Cardano reward", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "stake1uyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqg6jacpu" + }, + "status": "valid" + }, + { + "name": "Cardano mismatched network header reward", + "account": { + "namespace": "cip34", + "reference": "1-764824073", + "address": "stake1uqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqg6qdfpm" + }, + "status": "invalid" + }, + { + "name": "unknown namespace", + "account": { + "namespace": "future", + "reference": "1", + "address": "nativeAddress" + }, + "status": "unsupported" + }, + { + "name": "unknown Bitcoin reference", + "account": { + "namespace": "bip122", + "reference": "unknown", + "address": "1C6Rc3w25VHud3dLDamutaqfKWqhrLRTaD" + }, + "status": "unsupported" + } +] diff --git a/test/operations_test.dart b/test/operations_test.dart new file mode 100644 index 0000000..fb92e95 --- /dev/null +++ b/test/operations_test.dart @@ -0,0 +1,639 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_connect/flutter_connect.dart' hide matches; + +final fixture = + jsonDecode( + File('test/fixtures/operations/conformance.json').readAsStringSync(), + ) + as Map; +final base = + (fixture['requests'] as List).first['request'] as Map; +const origin = 'https://example.com'; +Map request([Map changes = const {}]) => { + ...jsonDecode(jsonEncode(base)) as Map, + ...changes, +}; + +class Account implements WalletAccount { + @override + final SigningSelection selection; + Account(WalletIdentity identity) + : selection = SigningSelection( + account: identity, + profile: 'test', + alg: null, + ); + @override + Future sign(Uint8List payload) async => + throw StateError('Authentication signer must not run'); +} + +class Provider implements WalletAccountProvider { + List available; + Provider(this.available); + @override + Future> getAccounts() async => available; +} + +class Transport implements OperationTransport { + final Future Function(String) send; + Transport(this.send); + @override + Future exchange(String request) => send(request); +} + +class Setup { + int approved = 0, executed = 0, validated = 0; + late final Provider provider; + late final OperationDispatcher dispatcher; + Setup({ + OperationApproval? approval, + bool noApproval = false, + FutureOr Function(ConnectOperation)? validate, + OperationHandler? handler, + String? Function(Object)? translate, + FutureOr Function(ConnectOperation)? prepare, + Object? Function(ConnectOperation, Object?)? normalize, + Duration timeout = const Duration(seconds: 30), + int maxRequests = 4096, + }) { + provider = Provider([Account(WalletIdentity.fromJson(base['account']))]); + final chain = OperationChain.fromJson(base['chain']); + final methods = {...OperationMethods.all, 'myapp.customOperation'}; + dispatcher = OperationDispatcher( + adapters: [ + OperationChainAdapter( + capabilities: [ + OperationCapability(chain.namespace, chain.reference, methods), + ], + handlers: { + for (final m in methods) + m: + handler ?? + (r, c) { + executed++; + return {'ok': true}; + }, + }, + validateAccount: (a) => a.address == base['account']['address'], + validateOperation: + validate ?? + (r) { + validated++; + if (r.params['invalid'] == true) { + throw OperationException('INVALID_TRANSACTION'); + } + }, + translateError: translate, + prepareMessage: prepare, + normalizeResult: normalize, + ), + ], + accounts: provider, + approve: noApproval + ? null + : approval ?? + (r, c, p) { + approved++; + return true; + }, + timeout: timeout, + maxRequests: maxRequests, + ); + } +} + +void main() { + for (final f in fixture['requests'] as List) { + test('shared operation fixture: ${f['name']}', () { + expect( + jsonDecode(ConnectOperation.fromJson(f['request']).serialize()), + f['request'], + ); + }); + } + test('responses, capabilities and correlation', () { + for (final r in fixture['responses'] as List) { + expect( + jsonDecode( + OperationResponse.fromJson( + r, + expectedRequestId: r['requestId'], + ).serialize(), + ), + r, + ); + } + final caps = parseCapabilities(fixture['capabilities']); + expect(caps.map((c) => c.toJson()).toList(), fixture['capabilities']); + expect( + supportsOperation( + caps, + OperationChain.fromJson(base['chain']), + OperationMethods.signPsbt, + ), + isTrue, + ); + expect( + () => OperationResponse.fromJson( + (fixture['responses'] as List).first, + expectedRequestId: 'f' * 64, + ), + throwsA(isA()), + ); + expect( + () => parseCapabilities([ + ...(fixture['capabilities'] as List), + ...(fixture['capabilities'] as List), + ]), + throwsA(isA()), + ); + expect( + () => OperationCapability('core', '1', ['wallet.unknown']), + throwsA(isA()), + ); + expect( + ConnectOperation( + operation: OperationMethods.getBalance, + chain: OperationChain('core', '1'), + params: {}, + ).requestId, + matches(RegExp(r'^[a-f0-9]{64}$')), + ); + }); + for (final method in {...OperationMethods.all, 'myapp.customOperation'}) { + test('dispatch $method approval and replay', () async { + final s = Setup(); + final response = await s.dispatcher.dispatch( + request({'operation': method}), + origin: origin, + ); + expect(response.toJson(), { + 'version': 1, + 'requestId': base['requestId'], + 'result': {'ok': true}, + }); + expect(s.executed, 1); + expect(s.approved, method.startsWith('chain.') ? 0 : 1); + expect( + (await s.dispatcher.dispatch( + request({'operation': method}), + origin: origin, + )).error!.code, + 'UNAUTHORIZED', + ); + }); + } + test('multiple namespaces, references and accounts', () async { + final entries = (fixture['requests'] as List).take(16); + final caps = {}; + final accounts = []; + for (final f in entries) { + final r = ConnectOperation.fromJson(f['request']); + final previous = caps[r.chain.key]; + caps[r.chain.key] = OperationCapability( + r.chain.namespace, + r.chain.reference, + {...?previous?.methods, r.operation}, + ); + accounts.add(Account(r.account!)); + } + final dispatcher = OperationDispatcher( + adapters: [ + OperationChainAdapter( + capabilities: caps.values, + handlers: { + for (final m in OperationMethods.all) + m: (r, c) => r.account!.address, + }, + validateAccount: (_) => true, + validateOperation: (_) {}, + ), + ], + accounts: Provider(accounts), + approve: (r, c, p) => true, + ); + for (final f in entries) { + expect( + (await dispatcher.dispatch(f['request'], origin: origin)).result, + f['request']['account']['address'], + ); + } + expect( + dispatcher.supports( + OperationChain.fromJson(base['chain']), + OperationMethods.signTypedData, + ), + isFalse, + ); + }); + final cases = , String)>{ + 'unknown chain': ( + { + 'chain': {'namespace': 'unknown', 'reference': '1'}, + 'account': null, + }, + 'UNSUPPORTED_CHAIN', + ), + 'unknown operation': ( + {'operation': 'wallet.unknown'}, + 'UNSUPPORTED_OPERATION', + ), + 'unregistered custom': ( + {'operation': 'myapp.unknown'}, + 'UNSUPPORTED_OPERATION', + ), + 'missing account': ({'account': null}, 'UNSUPPORTED_ACCOUNT'), + 'invalid account': ( + { + 'account': {...base['account'] as Map, 'address': 'bad'}, + }, + 'UNSUPPORTED_ACCOUNT', + ), + 'mismatched account': ( + { + 'account': {...base['account'] as Map, 'reference': '2'}, + }, + 'UNSUPPORTED_ACCOUNT', + ), + 'invalid transaction': ( + { + 'params': {'invalid': true}, + }, + 'INVALID_TRANSACTION', + ), + }; + for (final entry in cases.entries) { + test(entry.key, () async { + final s = Setup(); + final value = request(entry.value.$1); + if (value['account'] == null) value.remove('account'); + expect( + (await s.dispatcher.dispatch(value, origin: origin)).error!.code, + entry.value.$2, + ); + expect(s.executed, 0); + expect(s.approved, 0); + }); + } + test('malformed and hostile payloads fail safely', () async { + final malformed = [ + null, + [], + '{', + request({'version': 2}), + request({'requestId': 'bad'}), + request({'params': []}), + request({ + 'params': {'private_key': 'secret'}, + }), + request({'extra': true}), + request({'operation': 'wallet.signMessage\n'}), + request({ + 'params': {'value': double.infinity}, + }), + request({ + 'params': {'value': 9007199254740992}, + }), + request({ + 'params': {'value': 'x' * 17000}, + }), + request({ + 'params': {'__proto__': {}}, + }), + ]; + Object deep = {}; + for (var i = 0; i < 35; i++) { + deep = {'next': deep}; + } + malformed.add(request({'params': deep})); + for (final value in malformed) { + expect( + () => ConnectOperation.fromJson(value), + throwsA(isA()), + ); + final r = await Setup().dispatcher.dispatch(value, origin: origin); + expect(r.error!.code, 'INVALID_PARAMS'); + expect(r.serialize().contains('secret'), isFalse); + } + expect( + (await Setup().dispatcher.dispatch('{', origin: origin)).requestId, + isNull, + ); + expect( + (await Setup().dispatcher.dispatch( + request({'version': 2}), + origin: origin, + )).requestId, + base['requestId'], + ); + expect( + () => OperationResponse.fromJson({ + ...fixture['responses'][0] as Map, + 'error': {'code': 'TIMEOUT', 'message': 'Timeout'}, + }), + throwsA(isA()), + ); + }); + test('deep snapshot resists caller mutation', () async { + final gate = Completer(); + ConnectOperation? observed; + final s = Setup( + approval: (r, c, p) { + observed = r; + return gate.future; + }, + ); + final value = request(); + final pending = s.dispatcher.dispatch(value, origin: origin); + await Future.delayed(Duration.zero); + value['params']['transaction']['value'] = '999'; + expect((observed!.params['transaction'] as Map)['value'], isNot('999')); + expect( + () => (observed!.params['transaction'] as Map)['value'] = '999', + throwsUnsupportedError, + ); + gate.complete(true); + expect((await pending).isSuccess, isTrue); + }); + test( + 'no implicit approval, rejection, failure and account revocation', + () async { + final missing = Setup(noApproval: true); + expect( + (await missing.dispatcher.dispatch(base, origin: origin)).error!.code, + 'UNAUTHORIZED', + ); + expect(missing.executed, 0); + final rejected = Setup(approval: (r, c, p) => false); + expect( + (await rejected.dispatcher.dispatch(base, origin: origin)).error!.code, + 'USER_REJECTED', + ); + expect(rejected.executed, 0); + final thrown = Setup( + approval: (r, c, p) => throw StateError('private secret'), + ); + final r = await thrown.dispatcher.dispatch(base, origin: origin); + expect(r.error!.code, 'UNAUTHORIZED'); + expect(r.serialize().contains('secret'), isFalse); + late Setup revoked; + revoked = Setup( + approval: (r, c, p) { + revoked.provider.available = []; + return true; + }, + ); + expect( + (await revoked.dispatcher.dispatch(base, origin: origin)).error!.code, + 'UNSUPPORTED_ACCOUNT', + ); + expect(revoked.executed, 0); + expect( + (await Setup().dispatcher.dispatch( + base, + origin: 'http://example.com', + )).error!.code, + 'UNAUTHORIZED', + ); + }, + ); + for (final item in { + 'wallet.signTransaction': 'SIGNING_FAILED', + 'wallet.sendTransaction': 'BROADCAST_FAILED', + 'chain.call': 'CHAIN_UNAVAILABLE', + }.entries) { + test('safe ${item.value}', () async { + final s = Setup( + handler: (r, c) => throw StateError('secret-internal-material'), + ); + final response = await s.dispatcher.dispatch( + request({'operation': item.key}), + origin: origin, + ); + expect(response.error!.code, item.value); + expect(response.error!.message, operationErrorMessages[item.value]); + expect(response.serialize().contains('secret'), isFalse); + }); + } + test('error codes, translation, preparation and normalization', () async { + for (final code in operationErrorMessages.keys) { + final s = Setup(validate: (_) => throw OperationException(code)); + expect( + (await s.dispatcher.dispatch(base, origin: origin)).error!.code, + code, + ); + } + final translated = Setup( + validate: (_) => throw StateError('opaque'), + translate: (_) => 'CHAIN_UNAVAILABLE', + ); + expect( + (await translated.dispatcher.dispatch(base, origin: origin)).error!.code, + 'CHAIN_UNAVAILABLE', + ); + Object? prepared; + final s = Setup( + prepare: (_) => {'bytes': '0102'}, + normalize: (r, v) => 'normalized', + approval: (r, c, p) { + prepared = p; + return true; + }, + ); + expect( + (await s.dispatcher.dispatch( + request({'operation': OperationMethods.signMessage}), + origin: origin, + )).result, + 'normalized', + ); + expect(prepared, {'bytes': '0102'}); + }); + test('timeout prevents late approval from signing', () async { + final gate = Completer(); + final s = Setup( + timeout: const Duration(milliseconds: 5), + approval: (r, c, p) => gate.future, + ); + expect( + (await s.dispatcher.dispatch(base, origin: origin)).error!.code, + 'TIMEOUT', + ); + gate.complete(true); + await Future.delayed(const Duration(milliseconds: 10)); + expect(s.executed, 0); + expect( + (await s.dispatcher.dispatch(base, origin: origin)).error!.code, + 'UNAUTHORIZED', + ); + }); + test('transport correlation, identities and bounded session', () async { + final s = Setup(); + final d = OperationDispatcher( + accounts: s.provider, + adapters: [ + OperationChainAdapter( + capabilities: [ + OperationCapability('core', '1', [OperationMethods.getAccounts]), + ], + handlers: { + OperationMethods.getAccounts: operationAccounts( + s.provider, + OperationChain('core', '1'), + ), + }, + validateAccount: (_) => true, + validateOperation: (_) {}, + ), + ], + approve: (r, c, p) => true, + ); + expect( + (await d.dispatch( + request({'operation': OperationMethods.getAccounts}), + origin: origin, + )).result, + [base['account']], + ); + expect( + (await requestOperation( + Transport((v) => s.dispatcher.dispatchJson(v, origin: origin)), + ConnectOperation.fromJson(base), + )).isSuccess, + isTrue, + ); + await expectLater( + requestOperation( + Transport( + (_) async => jsonEncode({ + ...fixture['responses'][0] as Map, + 'requestId': 'f' * 64, + }), + ), + ConnectOperation.fromJson(base), + ), + throwsA(isA()), + ); + final bounded = Setup(maxRequests: 1).dispatcher; + await bounded.dispatch(base, origin: origin); + expect( + (await bounded.dispatch( + request({'requestId': 'f' * 64}), + origin: origin, + )).error!.code, + 'UNAUTHORIZED', + ); + }); + test( + 'registration, unadvertised capabilities, duplicate pending IDs and unsafe results', + () async { + final chain = OperationChain.fromJson(base['chain']); + final adapter = OperationChainAdapter( + capabilities: [ + OperationCapability(chain.namespace, chain.reference, [ + OperationMethods.getBalance, + ]), + ], + handlers: {OperationMethods.getBalance: (r, c) => null}, + validateAccount: (_) => true, + validateOperation: (_) {}, + ); + expect( + () => OperationDispatcher(adapters: [adapter, adapter]), + throwsA(isA()), + ); + expect( + () => OperationDispatcher( + adapters: [ + OperationChainAdapter( + capabilities: adapter.capabilities, + handlers: {}, + validateAccount: (_) => true, + validateOperation: (_) {}, + ), + ], + ), + throwsA(isA()), + ); + expect( + (await OperationDispatcher( + adapters: [adapter], + ).dispatch(base, origin: origin)).error!.code, + 'UNSUPPORTED_OPERATION', + ); + final gate = Completer(); + final pending = Setup(approval: (r, c, p) => gate.future); + final first = pending.dispatcher.dispatch(base, origin: origin); + expect( + (await pending.dispatcher.dispatch(base, origin: origin)).error!.code, + 'UNAUTHORIZED', + ); + gate.complete(true); + expect((await first).isSuccess, isTrue); + expect(pending.executed, 1); + for (final result in [ + {'privateKey': 'secret'}, + BigInt.one, + ]) { + final response = await Setup( + handler: (r, c) => result, + ).dispatcher.dispatch(base, origin: origin); + expect(response.error, isNotNull); + expect(response.serialize().contains('secret'), isFalse); + } + }, + ); + test('shared encrypted fixture, roundtrips and tampering', () async { + final f = fixture['encrypted']; + final key = List.filled(32, 1); + final cipher = OperationCipher(f['origin'], f['request']['requestId'], key); + final r = ConnectOperation.fromJson(f['request']); + expect( + (await cipher.openRequest(jsonEncode(f['packet']))).toJson(), + r.toJson(), + ); + final req = await cipher.sealRequest(r); + expect((await cipher.openRequest(req)).toJson(), r.toJson()); + final response = OperationResponse.fromJson(fixture['responses'][0]); + final res = await cipher.sealResponse(response); + expect((await cipher.openResponse(res)).toJson(), response.toJson()); + await expectLater( + cipher.openRequest(res), + throwsA(isA()), + ); + await expectLater( + OperationCipher( + 'https://evil.example', + r.requestId, + key, + ).openRequest(jsonEncode(f['packet'])), + throwsA(isA()), + ); + await expectLater( + cipher.openRequest( + jsonEncode({...f['packet'] as Map, 'ciphertext': 'AAAA'}), + ), + throwsA(isA()), + ); + final input = Platform.environment['CONNECT_OPERATION_PACKETS']; + if (input != null) { + final p = jsonDecode(File(input).readAsStringSync()); + expect((await cipher.openRequest(p['request'])).toJson(), r.toJson()); + expect( + (await cipher.openResponse(p['response'])).toJson(), + response.toJson(), + ); + } + final output = Platform.environment['DART_OPERATION_PACKETS']; + if (output != null) { + File( + output, + ).writeAsStringSync(jsonEncode({'request': req, 'response': res})); + } + }); +} diff --git a/test/wallet_validation_test.dart b/test/wallet_validation_test.dart new file mode 100644 index 0000000..a585358 --- /dev/null +++ b/test/wallet_validation_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_connect/flutter_connect.dart'; + +void main() { + final cases = + jsonDecode( + File('test/fixtures/wallet-validation.json').readAsStringSync(), + ) + as List; + for (final c in cases) { + test('address: ${c['name']}', () { + WalletValidationStatus status; + try { + final account = WalletIdentity.fromJson(c['account']); + status = validateWalletAccount(account).status; + expect(isValidWalletAccount(account), c['status'] == 'valid'); + } on ConnectException { + status = WalletValidationStatus.invalid; + } + expect(status.name, c['status']); + }); + } + test( + 'wrapper rejects bad checksum before host policy and retains host rejection', + () async { + var called = 0; + final adapter = withWalletValidation( + OperationChainAdapter( + capabilities: [], + handlers: {}, + validateAccount: (_) { + called++; + return true; + }, + validateOperation: (_) {}, + ), + ); + WalletIdentity identity(String name) => WalletIdentity.fromJson( + cases.firstWhere((c) => c['name'] == name)['account'], + ); + expect( + await adapter.validateAccount(identity('EVM bad mixed checksum')), + isFalse, + ); + expect(called, 0); + expect(await adapter.validateAccount(identity('ethereum-siwe')), isTrue); + expect(called, 1); + expect(await adapter.validateAccount(identity('Monero')), isTrue); + expect(called, 2); + final denied = withWalletValidation( + OperationChainAdapter( + capabilities: [], + handlers: {}, + validateAccount: (_) => false, + validateOperation: (_) {}, + ), + ); + expect(await denied.validateAccount(identity('ethereum-siwe')), isFalse); + }, + ); + test('dispatcher rejects bad checksum before approval and handler', () async { + final account = WalletIdentity.fromJson( + cases.firstWhere((c) => c['name'] == 'EVM bad mixed checksum')['account'], + ); + var approved = 0, executed = 0; + final dispatcher = OperationDispatcher( + adapters: [ + withWalletValidation( + OperationChainAdapter( + capabilities: [ + OperationCapability(account.namespace, account.reference, [ + OperationMethods.getBalance, + ]), + ], + handlers: { + OperationMethods.getBalance: (r, c) { + executed++; + return '0'; + }, + }, + validateAccount: (_) => true, + validateOperation: (_) {}, + ), + ), + ], + approve: (r, c, p) { + approved++; + return true; + }, + ); + final response = await dispatcher.dispatch( + ConnectOperation( + operation: OperationMethods.getBalance, + chain: OperationChain(account.namespace, account.reference), + account: account, + params: {}, + ), + origin: 'https://example.com', + ); + expect(response.error!.code, 'UNSUPPORTED_ACCOUNT'); + expect(approved, 0); + expect(executed, 0); + }); +}