diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 23357f37..e1bd24c9 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -91,6 +91,19 @@ "email": "E-Mail", "enable": "Aktivieren", "endDate": "Enddatum", + "errorBitboxBtcPsbtInvalid": "Die BTC-Transaktion hat die Vorprüfung nicht bestanden. Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.", + "errorBitboxChannelHashMismatch": "Der Pairing-Channel-Hash stimmt nicht überein. Bitte koppeln Sie Ihre BitBox erneut.", + "errorBitboxInvalidInput": "Ihre BitBox hat die Anfrage als ungültig zurückgewiesen. Bitte entfernen Sie nicht-lateinische Zeichen aus Ihrer Eingabe und versuchen Sie es erneut.", + "errorBitboxNotConnected": "Die Verbindung zur BitBox wurde unterbrochen. Bitte erneut verbinden und nochmals versuchen.", + "errorBitboxTimeout": "Die BitBox hat nicht rechtzeitig geantwortet. Bitte erneut verbinden und nochmals versuchen.", + "errorBitboxUnknown": "Ein unbekannter BitBox-Fehler ist aufgetreten. Bitte erneut verbinden und nochmals versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.", + "errorBitboxUserAbort": "Sie haben die Aktion auf der BitBox abgebrochen. Bitte erneut versuchen, sobald Sie bereit sind.", + "errorEip1559TypeMismatch": "Die Transaktion ist fehlerhaft formatiert (EIP-1559 Typ-Byte stimmt nicht überein). Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.", + "errorEip712SchemaDrift": "Der Server hat ein unerwartetes Signaturschema zurückgegeben. Die Wallet hat zu Ihrer Sicherheit die Signatur verweigert. Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.", + "errorEip7702ExpectedParamsMismatch": "Der Server hat unerwartete Delegations-Parameter zurückgegeben. Die Wallet hat zu Ihrer Sicherheit die Signatur verweigert. Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.", + "errorEip7702NotSupported": "Ihre BitBox-Firmware unterstützt EIP-7702-Delegationen noch nicht. Bitte aktualisieren Sie die Firmware, um fortzufahren.", + "errorSigningCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen.", + "errorSignRequestInvalid": "Die Signaturanforderung ist ungültig. Bitte korrigieren Sie Ihre Eingabe und versuchen Sie es erneut.", "fee": "Gebühr", "financialData": "Finanzdaten", "financialDataQuestion": "Frage ${current} von ${total}", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 6ada4d8a..eed01ba0 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -91,6 +91,19 @@ "email": "Email", "enable": "Enable", "endDate": "End date", + "errorBitboxBtcPsbtInvalid": "The BTC transaction failed pre-flight validation. Please retry; if the problem persists, contact support.", + "errorBitboxChannelHashMismatch": "The pairing channel hash does not match. Please re-pair your BitBox.", + "errorBitboxInvalidInput": "Your BitBox rejected the request as invalid. Please remove non-Latin characters from your input and try again.", + "errorBitboxNotConnected": "The connection to the BitBox was lost. Please reconnect and try again.", + "errorBitboxTimeout": "The BitBox did not respond in time. Please reconnect and try again.", + "errorBitboxUnknown": "An unknown BitBox error occurred. Please reconnect and try again; if the problem persists, contact support.", + "errorBitboxUserAbort": "You cancelled the action on the BitBox. Please retry when ready.", + "errorEip1559TypeMismatch": "The transaction payload is malformed (EIP-1559 type byte mismatch). Please retry; if the problem persists, contact support.", + "errorEip712SchemaDrift": "The server returned an unexpected signing schema. The wallet refused to sign for your safety. Please retry; if the problem persists, contact support.", + "errorEip7702ExpectedParamsMismatch": "The server returned unexpected delegation parameters. The wallet refused to sign for your safety. Please retry; if the problem persists, contact support.", + "errorEip7702NotSupported": "Your BitBox firmware does not yet support EIP-7702 delegations. Please update the firmware to continue.", + "errorSigningCancelled": "Signature cancelled — please confirm on the BitBox again.", + "errorSignRequestInvalid": "The sign request is invalid. Please correct your input and try again.", "fee": "Fee", "financialData": "Financial data", "financialDataQuestion": "Question ${current} of ${total}", diff --git a/docs/adr/0002-sign-pipeline-architecture.md b/docs/adr/0002-sign-pipeline-architecture.md new file mode 100644 index 00000000..197fd51f --- /dev/null +++ b/docs/adr/0002-sign-pipeline-architecture.md @@ -0,0 +1,216 @@ +# ADR 0002 — Sign Pipeline Architecture + +- **Status:** Proposed (Initiative II) +- **Date:** 2026-05-23 +- **Initiative:** II — Sign Pipeline Defense-in-Depth +- **Related findings:** F-002, F-003, F-018, F-019, F-020, F-021, F-030, F-031, F-038, F-039, F-040, F-041, F-042 +- **Related backlog:** BL-002, BL-005, BL-006, BL-020/021, BL-025, BL-027..BL-031, BL-035, BL-068..BL-070, BL-073 + +## Context + +The current sign surface is a `static` helper (`Eip712Signer.signRegistration` / +`signDelegation`) called directly from six different code paths — the KYC +`completeRegistration`, the merge-confirm `registerWallet`, the EIP-7702 sell +`confirmPayment`, the EIP-7702 sell `signAuthorization`, the `DFXAuthService` +auth-message sign, and (future) the BTC PSBT sell path. Each callsite owns its +own romanisation, its own validation, its own type-byte handling, and its own +error translation. + +Concrete consequences observed in the 2026-05-23 audit: + +- **F-038** — `signDelegation` builds the EIP-712 types map from + backend-supplied `Eip7702Types`. A malicious / MITM-ed backend can inject a + hidden field; the user signs a delegation they cannot see in the + validation UI. +- **F-041** — `signRegistration`'s EIP-712 domain has no `chainId`. Same + signature replays across chains and backends. +- **F-040** — `BitboxCredentials.signToSignature` strips `payload[0]` for + EIP-1559 without asserting it actually is the `0x02` type byte. A caller + that mislabels a legacy payload silently corrupts the signed bytes. +- **F-019** — Romanisation is applied at the registration callsite but the + `kycData` sub-object intentionally keeps UTF-8. A future caller can forget + the romanisation step and break the signed/stored byte-equality contract. +- **F-002** — `swissTaxResidence: true` is hardcoded at the page layer and + flows verbatim into the signed envelope. There is no form control. The + contract between "what the user attests" and "what they sign" is broken at + the very edge. +- **F-042** — `registrationDate` is generated client-side from + `DateTime.now()`. A jail-broken device clock signs an arbitrary date. +- **F-003 / F-016 / F-020 / F-021** — Cubits do `catch (e) { e.toString() }` + string-matching to recover the BitBox cause from a generic failure. Any + type renamed downstream silently drops the special handling. + +The worst-case adversary is a compromised DFX backend (or MITM with TLS +intercept) that returns an EIP-7702 schema with an extra `{name: +"secretApproval", type: "uint256"}` field; the user sees the visible amount +in the validation UI, taps sign, and the BitBox signs a schema the user can +never inspect after the fact. + +## Decision + +Introduce a single sign **pipeline** that owns every step between +`SignRequest` and `SignResult`. The Dart side never reaches the BitBox plugin +outside this pipeline. + +``` +SignRequest ──► validate ──► romanise ──► pinSchema ──► submitToBitbox ──► mapResult ──► SignResult + │ │ │ │ │ + │ │ │ │ └─ typed `SignException` hierarchy + │ │ │ └─ sole callsite of the BitBox plugin + │ │ └─ byte-equal compare backend types against schema constant + │ └─ `toBitboxSafeAscii` on every user string in envelope AND DTO + └─ field-presence + type contracts on the request itself +``` + +```mermaid +flowchart LR + Req[SignRequest] --> Val[_validate] + Val --> Rom[_romanise] + Rom --> Pin[_pinSchema] + Pin --> Sub[_submitToBitbox] + Sub --> Map[_mapResult] + Map --> Res[SignResult] + Val -.->|"validation failure"| Err1[SignException] + Rom -.->|"unromanisable input"| Err1 + Pin -.->|"schema drift"| Err2[Eip712SchemaDriftException] + Sub -.->|"bitbox plugin throws"| Err3[BitBox typed exceptions] + Map -.->|"unknown native code"| Err4[BitboxUnknownException] +``` + +### Concrete commitments + +1. **`Eip712Signer` becomes a DI-injected service**, not a static helper. The + `SoftwareWallet` path remains synchronous, but callsites depend on the + abstraction and tests substitute a fake. +2. **Schema classes** (`RegistrationSchemaV1`, `KycSignSchema`, + `Eip7702DelegationSchema`, `BtcPsbtSchema`) are compile-time `const` + objects. Their `types` map IS the trusted client-side schema. Backend + responses are compared **byte-equal** against this constant; any + extra / missing / reordered / wrong-type field raises + `Eip712SchemaDriftException` BEFORE the plugin sees any byte. +3. **`SignPipeline`** is the single entry. Six variants of + `sealed class SignRequest` (Registration, Kyc, Sell, Eip7702, BtcPsbt, + EthTransfer). No alternate "I'll just call the signer directly" path. +4. **Romanisation invariant**: `pipeline(s).envelope == pipeline(s).dto` + byte-equal for every user string. Tests pin this as a property. +5. **`signDelegation`** takes explicit `expectedVerifyingContract`, + `expectedChainId`, `expectedDelegator`, `expectedAmount` parameters. The + signer validates internally and refuses to delegate to "validate over + there" — encapsulation is back inside the trust boundary. +6. **`chainId` in registration domain** (F-041). Property test pins the + cross-chain replay safety. +7. **`payload[0] == 0x02` assert before EIP-1559 strip** (F-040). Runtime + check that throws `Eip1559TypeMismatchException` in release; assert in + debug as a developer-experience signal. +8. **`registrationDate` from server clock** (F-042). The request carries the + server-issued timestamp; the client never signs `DateTime.now()`. +9. **`ErrorMapper`** maps native BitBox error codes (101 = invalid input, + etc.) to typed `SignException` subclasses, with each typed exception + carrying an i18n ARB key. An exhaustive test fails the build if a code + has no mapping. +10. **`KycEmailVerificationCubit`** routes `BitboxNotConnectedException` + to a typed `KycEmailVerificationBitboxRequired` state instead of + swallowing into a generic `RegistrationFailure`. The sign-gate flip + moves inside the cubit's success branch (F-018). + +## Alternatives considered + +1. **Static helper + caller-validates.** Status quo. Rejected because every + new callsite re-implements romanisation / schema-pinning / error-mapping + from memory; the audit found six callsites with five different shapes. +2. **Top-level functions in a `sign.dart` library.** Same testability problem + as the static helper — no DI seam, hard to substitute a fake, every test + pays for the real eth_sig_util. +3. **Code-gen schemas from a backend OpenAPI / JSON-Schema spec.** Tempting + because it would close the byte-equality loop automatically. Rejected for + this initiative because: (a) DFX backend does not publish a JSON-Schema + today, (b) "the schema is what the backend says it is" is the F-038 bug, + not the fix. The whole point of pinning is that the client must NOT + trust whatever the backend currently happens to publish. +4. **Runtime-fetched schemas from a versioned endpoint with separate + signing key.** Conceptually stronger because it lets the schema evolve + without app updates. Rejected as out-of-scope for Initiative II — needs a + coordinated backend deliverable and a separate trust root. The current + ADR keeps schemas in client source; ADR 0003 (Initiative IV) can revisit. +5. **Single mega-signer class that absorbs `SoftwareWallet` and BitBox + together.** Rejected because Initiative IV is moving `SoftwareWallet` + behind an isolate IPC seam. Letting the signer reach into the wallet + directly would conflict with that refactor. + +## Consequences + +### Positive + +- **One callsite to audit.** Schema-pinning, romanisation, type-byte assert, + error-mapping all live in one place. A new sign use-case files a new + `SignRequest` variant and goes through the same `_validate → _romanise → + _pinSchema → _submitToBitbox → _mapResult` path. +- **`e.toString()` string-matching dies.** Cubits switch on typed + exceptions; the ARB key is owned by the exception, not by the caller. +- **Property-tested cross-chain safety.** `chainId` differs → signature + differs is a fuzz-property the CI runs forever. +- **Defence against a malicious backend.** Extra-field attack surfaces as a + typed exception **before** the BitBox sees any byte. +- **Coverage gate is enforceable.** Pipeline + signer + error-mapper + + schemas all live in `lib/packages/wallet/`; the existing branch-coverage + policy can require ≥ 95 % on that directory. + +### Negative / risks + +- **Schema drift from backend is now a build failure waiting to happen.** If + the backend ships a v2 schema before the app catches up, registration + breaks. Mitigation: versioned schemas (`RegistrationSchemaV1`, `V2`); the + pipeline tries each known version in turn before declaring drift. +- **Coordinated backend change for `chainId`.** Adding `chainId` to the + domain changes the signed hash. Until backend accepts the new domain, the + field is sent as non-signed metadata. Tracked in the journal; deadline + pinned by Initiative II acceptance gate (§6.II). +- **DI cost.** Every callsite now resolves the signer + schema from the + container instead of calling `Eip712Signer.signRegistration` directly. + Small ergonomic cost; pays for itself in testability. +- **One more layer to learn.** New contributors have to read + `sign_pipeline.dart` before adding a sign flow. The ADR exists so the + read is short. + +### Failure modes (and what catches them) + +| Failure mode | Caught by | +| ------------------------------------------------- | -------------------------------------------------------- | +| Backend returns extra field in EIP-7702 schema | `_pinSchema` byte-equal compare → `Eip712SchemaDriftException` | +| Romanisation skipped on a new DTO field | Property test `pipeline(s).envelope == pipeline(s).dto` | +| `chainId` change replays on a different chain | Property test "differing chainId → differing sig" | +| Native firmware ships a new error code | `ErrorMapper` exhaustiveness test → build red until mapped | +| Caller assumes `isEIP1559` without `0x02` prefix | `payload[0] == 0x02` assert → `Eip1559TypeMismatchException` | +| New cubit re-implements `catch (e) { e.toString() }` | `grep` lint in CI; ErrorMapper is the only allowed router | +| `swissTaxResidence` UI binding lost in refactor | Form validator + property test on envelope value | +| `registrationDate` regresses to `DateTime.now()` | Request carries the server-issued timestamp; client-clock fallback removed | + +## Implementation order + +1. ADR 0002 (this document). +2. Schema base + `RegistrationSchemaV1` + tests pinning byte-equal compare. +3. `KycSignSchema`, `Eip7702DelegationSchema`, `BtcPsbtSchema` + drift-rejection tests. +4. `ErrorMapper` + exhaustive mapping table + i18n keys. +5. `SignPipeline` with six `SignRequest` variants + pipeline-step unit tests. +6. `Eip712Signer` static → DI refactor; preserve backward-compatible static + wrappers for `RealUnitRegistrationService` / `RealUnitSellPaymentInfoService` + until both are migrated to the pipeline. +7. EIP-7702 schema pinning with explicit expected params. +8. `chainId` in registration domain (with backend-coordinated rollout). +9. `payload[0] == 0x02` assert in `BitboxCredentials.signToSignature`. +10. Six-entrypoint Tier-1 integration test against `FakeBitboxCredentials`. +11. `swissTaxResidence` form input + country-derived default. +12. `KycEmailVerificationCubit` typed routing + sign-gate move + latch reset. +13. 13-page disconnect-mid-sign Tier-1 integration test. + +## Acceptance gate (§6.II) + +- ADR 0002 accepted, TF-reviewed. +- `Eip712Signer` injected service; every callsite via DI. +- Six entrypoint Tier-1 test green. +- Romanisation property test green. +- Schema-pinning Tier-0 + (Tier-2 testkit) green. +- ErrorMapper exhaustive test green; zero `e.toString()` string-matching in cubits. +- `swissTaxResidence` form input live; TF #526 closeable. +- `chainId` in domain; cross-chain replay property test green. +- All in-scope backlog items `done` with regression-index entries. diff --git a/lib/packages/hardware_wallet/bitbox_credentials.dart b/lib/packages/hardware_wallet/bitbox_credentials.dart index 61f8b892..6ac444c0 100644 --- a/lib/packages/hardware_wallet/bitbox_credentials.dart +++ b/lib/packages/hardware_wallet/bitbox_credentials.dart @@ -6,6 +6,7 @@ import 'package:bitbox_flutter/bitbox_manager.dart'; import 'package:convert/convert.dart' as convert; import 'package:flutter/foundation.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; @@ -116,6 +117,17 @@ class BitboxCredentials extends CredentialsWithKnownAddress { int? chainId, bool isEIP1559 = false, }) { + // F-040 — refuse to strip the leading type byte unless it is + // actually the EIP-2718 `0x02` envelope. Run BEFORE the + // connection check so a caller that mislabels a legacy transaction + // as EIP-1559 is told the truth (type-byte mismatch) rather than + // the unrelated truth (BitBox not connected). The assertion is + // structural input validation, not a runtime-dependent check. + if (isEIP1559 && (payload.isEmpty || payload[0] != 0x02)) { + throw Eip1559TypeMismatchException( + actualByte: payload.isEmpty ? null : payload[0], + ); + } return _synchronizeBoundedSign(() async { // Snapshot the manager + path up-front so an observer-driven null-out // between the connection check and the sign call doesn't NoSuchMethod. diff --git a/lib/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart b/lib/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart index c01070f6..bcbe3d3d 100644 --- a/lib/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart +++ b/lib/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart @@ -64,7 +64,14 @@ class Eip7702Message { final String delegator; final String authority; final List caveats; - final int salt; + + // `salt` is a `uint256` (MetaMask Delegation Framework). A 256-bit value + // does not fit in a Dart `int` (64-bit, and only 53-bit precision on web), + // so parsing it as `int` silently truncates/overflows and the signed salt + // no longer matches what the backend issued. Keep it as a `BigInt` and only + // collapse to a decimal string at the EIP-712 / DTO boundary, where the + // uint256 encoder reads number-or-decimal-string identically. + final BigInt salt; const Eip7702Message({ required this.delegate, @@ -80,7 +87,9 @@ class Eip7702Message { delegator: json['delegator'] as String, authority: json['authority'] as String, caveats: json['caveats'] as List, - salt: json['salt'] as int, + // Accept both a JSON number and a decimal string — the backend may send + // a large salt as a string to avoid JSON number-precision loss. + salt: BigInt.parse(json['salt'].toString()), ); } } @@ -89,7 +98,10 @@ class Eip7702Data { final String relayerAddress; final String delegationManagerAddress; final String delegatorAddress; - final int userNonce; + // EIP-7702 authorization nonce is a `uint64`; values above 2^63 overflow a + // Dart `int` (and above 2^53 lose precision on web). Hold it as a `BigInt` + // so the authorization tuple is signed with the exact nonce. + final BigInt userNonce; final Eip7702Domain domain; final Eip7702Types types; final Eip7702Message message; @@ -115,7 +127,7 @@ class Eip7702Data { relayerAddress: json['relayerAddress'] as String, delegationManagerAddress: json['delegationManagerAddress'] as String, delegatorAddress: json['delegatorAddress'] as String, - userNonce: json['userNonce'] as int, + userNonce: BigInt.parse(json['userNonce'].toString()), domain: Eip7702Domain.fromJson(json['domain'] as Map), types: Eip7702Types.fromJson(json['types'] as Map), message: Eip7702Message.fromJson(json['message'] as Map), diff --git a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart index e6ec146f..2777b174 100644 --- a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart +++ b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart @@ -90,9 +90,29 @@ class RealUnitSellPaymentInfoService extends DFXAuthService { final credentials = appStore.wallet.currentAccount.primaryAddress; _validateEip7702Data(paymentInfo.eip7702, credentials.address.hexEip55, paymentInfo.amount); - final delegationSignature = await Eip712Signer.signDelegation( + // Sign through the hardened envelope, NOT the legacy static wrapper. The + // legacy `signDelegation` rebuilt the typed-data `types` verbatim from + // the backend payload, so a backend that appended a hidden field (e.g. + // `{name: "secretApproval", type: "uint256"}`) to `Delegation`/`Caveat` + // would have it silently signed by the BitBox. `signDelegationEnvelope` + // pins the `types` against the client-side schema and re-validates the + // trusted parameters (verifyingContract / chainId / delegator / amount / + // domain name+version) before any byte reaches the device. On the happy + // path the signed envelope is byte-identical to the legacy one, so the + // signature still verifies backend-side. + final expectedWei = BigInt.from(paymentInfo.amount) * + BigInt.from(10).pow(appStore.apiConfig.asset.decimals); + final delegationSignature = await const Eip712Signer().signDelegationEnvelope( credentials: credentials, eip7702Data: paymentInfo.eip7702, + expectedVerifyingContract: _delegationManagerAddress, + expectedChainId: appStore.apiConfig.asset.chainId, + expectedDelegator: credentials.address.hexEip55, + expectedAmount: expectedWei, + // Domain name/version pinned to the RealUnit DelegationManager domain + // (see test/integration/eip7702_delegation_bitbox_test.dart fixtures). + expectedDomainName: 'RealUnit', + expectedDomainVersion: '1', ); final authorizationSignature = Eip7702Signer.signAuthorization( credentials: credentials, @@ -112,7 +132,11 @@ class RealUnitSellPaymentInfoService extends DFXAuthService { authorization: Eip7702AuthorizationDto( chainId: paymentInfo.eip7702.domain.chainId, address: paymentInfo.eip7702.delegatorAddress, - nonce: paymentInfo.eip7702.userNonce, + // Echo the authorization nonce as a JSON number (the backend + // contract). The exact uint64 is preserved on the security- + // critical path — signAuthorization signs the full BigInt; this + // is just the metadata echo the backend cross-checks. + nonce: paymentInfo.eip7702.userNonce.toInt(), r: '0x${authorizationSignature.r.toRadixString(16)}', s: '0x${authorizationSignature.s.toRadixString(16)}', yParity: authorizationSignature.yParity, diff --git a/lib/packages/utils/ascii_transliterate.dart b/lib/packages/utils/ascii_transliterate.dart index c4f09ee8..ec5eda3a 100644 --- a/lib/packages/utils/ascii_transliterate.dart +++ b/lib/packages/utils/ascii_transliterate.dart @@ -96,6 +96,13 @@ const Map _singleCharReplacements = { '”': '"', // ” right double quote '–': '-', // – en dash '—': '-', // — em dash + // Guillemets — the quotation marks used in Swiss French/Italian text. Without + // these, an address line like `«Le Château»` was signed as `?Le Château?` + // (placeholder loss) and the BitBox payload no longer matched the backend's. + '«': '"', // « left-pointing double angle quote + '»': '"', // » right-pointing double angle quote + '‹': "'", // ‹ left-pointing single angle quote + '›': "'", // › right-pointing single angle quote }; /// Returns an ASCII-safe representation of [input]. German umlauts and diff --git a/lib/packages/wallet/eip712_signer.dart b/lib/packages/wallet/eip712_signer.dart index f19f0e78..7ab8cf13 100644 --- a/lib/packages/wallet/eip712_signer.dart +++ b/lib/packages/wallet/eip712_signer.dart @@ -1,14 +1,83 @@ +// EIP-712 / EIP-7702 signer for the Dart side of the wallet. +// +// Initiative II refactor (ADR 0002): +// +// * was: a `class Eip712Signer { static Future signRegistration(...) }` +// helper called directly from six different code paths. +// * is now: a DI-injected service that the [SignPipeline] holds. Existing +// `static` entrypoints are preserved as thin wrappers around a default +// instance so the in-tree consumers +// (`RealUnitRegistrationService`, `RealUnitSellPaymentInfoService`) can +// migrate to the pipeline incrementally — see commit log for the planned +// migration order. +// +// New surface (instance methods on a `const`-constructible class): +// +// const Eip712Signer() +// +// Future signRegistrationEnvelope({...}) — instance method +// building the `RealUnitUser` typed-data envelope. Domain includes +// `chainId` (F-041 fix) and `verifyingContract` when the supplied +// [schema] expects them; otherwise (V0 schema) falls back to the legacy +// `name + version` shape for the backend-rollout window. +// +// Future signDelegationEnvelope({ +// required CredentialsWithKnownAddress credentials, +// required Eip7702Data eip7702Data, +// required String expectedVerifyingContract, +// required int expectedChainId, +// required String expectedDelegator, +// required BigInt expectedAmount, +// Eip7702DelegationSchema schema, +// }) — schema-pinning lives inside the signer (F-039 closure). A future +// caller cannot forget the validation step. +// +// Future signKycEnvelope({...}) — pinned via [KycSignSchema]; +// today this is exercised by the pipeline tests, production wiring lands +// when NEW-19 closes. +// +// Future signTypedDataEnvelope({...}) — low-level entrypoint +// used by [SignPipeline] when it constructs its own envelope. Routes +// straight through to the platform signer. +// +// Backward-compat static methods: +// +// `Eip712Signer.signRegistration(...)` and `.signDelegation(...)` +// continue to work — they delegate to a default `const Eip712Signer()`. +// Both legacy callsites remain working while the pipeline migration +// rolls out. Existing tier-0 test +// `test/packages/wallet/eip712_signer_test.dart` exercises the legacy +// path; the new pipeline tests exercise the DI surface. + import 'dart:convert'; import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip7702_delegation_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/kyc_sign_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/registration_schema.dart'; import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; class Eip712Signer { - static Future signRegistration({ + /// Default constructor uses production wiring (real BitBox plugin / + /// real `eth_sig_util_plus`). Const so callers can hold a stable + /// default instance via `const Eip712Signer()`. + const Eip712Signer(); + + // ------------------------------------------------------------------------ + // Instance methods (the DI surface) + // ------------------------------------------------------------------------ + + /// Builds the `RealUnitUser` EIP-712 envelope and signs it. The domain + /// includes `chainId` (F-041 fix) and `verifyingContract` when the + /// supplied [schema] expects them; otherwise (V0 schema) falls back to + /// the legacy `name + version` shape. + Future signRegistrationEnvelope({ required CredentialsWithKnownAddress credentials, required int chainId, required String email, @@ -23,31 +92,22 @@ class Eip712Signer { required String addressCountry, required bool swissTaxResidence, required String registrationDate, + String? verifyingContract, + Eip712Schema schema = const RegistrationSchemaV0(), }) { + final domain = { + 'name': 'RealUnitUser', + 'version': '1', + if (schema.types['EIP712Domain']!.any((f) => f.name == 'chainId')) + 'chainId': chainId, + if (schema.types['EIP712Domain']!.any((f) => f.name == 'verifyingContract') && + verifyingContract != null) + 'verifyingContract': verifyingContract, + }; final Map typedDataMap = { - 'types': { - 'EIP712Domain': [ - {'name': 'name', 'type': 'string'}, - {'name': 'version', 'type': 'string'}, - ], - 'RealUnitUser': [ - {'name': 'email', 'type': 'string'}, - {'name': 'name', 'type': 'string'}, - {'name': 'type', 'type': 'string'}, - {'name': 'phoneNumber', 'type': 'string'}, - {'name': 'birthday', 'type': 'string'}, - {'name': 'nationality', 'type': 'string'}, - {'name': 'addressStreet', 'type': 'string'}, - {'name': 'addressPostalCode', 'type': 'string'}, - {'name': 'addressCity', 'type': 'string'}, - {'name': 'addressCountry', 'type': 'string'}, - {'name': 'swissTaxResidence', 'type': 'bool'}, - {'name': 'registrationDate', 'type': 'string'}, - {'name': 'walletAddress', 'type': 'address'}, - ], - }, - 'primaryType': 'RealUnitUser', - 'domain': {'name': 'RealUnitUser', 'version': '1'}, + 'types': schema.typesAsJson(), + 'primaryType': schema.primaryType, + 'domain': domain, 'message': { 'email': email, 'name': name, @@ -65,29 +125,110 @@ class Eip712Signer { }, }; - return _signTypedData(credentials, chainId, jsonEncode(typedDataMap)); + return signTypedDataEnvelope( + credentials: credentials, + chainId: chainId, + jsonEnvelope: jsonEncode(typedDataMap), + ); } - static Future signDelegation({ + /// EIP-7702 delegation sign with explicit pinned-parameter validation + /// and schema-pinning. Closes F-038 / F-039 — a backend adding + /// `{name: "secretApproval", type: "uint256"}` is refused before any + /// byte reaches the BitBox plugin. + /// + /// The expected pinned parameters are validated INSIDE the signer + /// rather than at the caller; that way a future caller cannot forget + /// the validation step. + Future signDelegationEnvelope({ required CredentialsWithKnownAddress credentials, required Eip7702Data eip7702Data, - }) { + required String expectedVerifyingContract, + required int expectedChainId, + required String expectedDelegator, + required BigInt expectedAmount, + // The EIP-712 domain `name` + `version` feed the domain separator that the + // user signs. They were previously unchecked, so a MITM/compromised backend + // could swap them to bind the signature to a different domain. Pinned when + // the caller supplies the expected values; left unvalidated (legacy + // behaviour) when null so other callers are unaffected. + String? expectedDomainName, + String? expectedDomainVersion, + Eip7702DelegationSchema schema = const Eip7702DelegationSchema(), + }) async { + // Pinned-parameter validation FIRST — refuse to construct the + // envelope if the backend has shifted any of the trusted parameters. + if (expectedDomainName != null && + eip7702Data.domain.name != expectedDomainName) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'domain.name', + expected: expectedDomainName, + actual: eip7702Data.domain.name, + ); + } + if (expectedDomainVersion != null && + eip7702Data.domain.version != expectedDomainVersion) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'domain.version', + expected: expectedDomainVersion, + actual: eip7702Data.domain.version, + ); + } + if (eip7702Data.domain.verifyingContract.toLowerCase() != + expectedVerifyingContract.toLowerCase()) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'verifyingContract', + expected: expectedVerifyingContract, + actual: eip7702Data.domain.verifyingContract, + ); + } + if (eip7702Data.domain.chainId != expectedChainId) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'chainId', + expected: '$expectedChainId', + actual: '${eip7702Data.domain.chainId}', + ); + } + if (eip7702Data.message.delegator.toLowerCase() != + expectedDelegator.toLowerCase()) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'delegator', + expected: expectedDelegator, + actual: eip7702Data.message.delegator, + ); + } + final actualWei = BigInt.tryParse(eip7702Data.amountWei); + if (actualWei == null || actualWei != expectedAmount) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'amountWei', + expected: '$expectedAmount', + actual: eip7702Data.amountWei, + ); + } + + // Schema-pinning — byte-equal compare backend types against the + // client-pinned [schema] constant. + final backendTypes = { + 'EIP712Domain': const [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + {'name': 'chainId', 'type': 'uint256'}, + {'name': 'verifyingContract', 'type': 'address'}, + ], + 'Delegation': [ + for (final f in eip7702Data.types.delegation) + {'name': f.name, 'type': f.type}, + ], + 'Caveat': [ + for (final f in eip7702Data.types.caveat) + {'name': f.name, 'type': f.type}, + ], + }; + schema.validate(backendTypes); + final Map typedDataMap = { - 'types': { - 'EIP712Domain': [ - {'name': 'name', 'type': 'string'}, - {'name': 'version', 'type': 'string'}, - {'name': 'chainId', 'type': 'uint256'}, - {'name': 'verifyingContract', 'type': 'address'}, - ], - 'Delegation': eip7702Data.types.delegation - .map((field) => {'name': field.name, 'type': field.type}) - .toList(), - 'Caveat': eip7702Data.types.caveat - .map((field) => {'name': field.name, 'type': field.type}) - .toList(), - }, - 'primaryType': 'Delegation', + 'types': schema.typesAsJson(), + 'primaryType': schema.primaryType, 'domain': { 'name': eip7702Data.domain.name, 'version': eip7702Data.domain.version, @@ -99,24 +240,83 @@ class Eip712Signer { 'delegator': eip7702Data.message.delegator, 'authority': eip7702Data.message.authority, 'caveats': eip7702Data.message.caveats, - 'salt': eip7702Data.message.salt, + // uint256 → decimal string; the typed-data encoder reads it identically + // to a JSON number, and a BigInt is not JSON-encodable. + 'salt': eip7702Data.message.salt.toString(), }, }; - return _signTypedData(credentials, eip7702Data.domain.chainId, jsonEncode(typedDataMap)); + return signTypedDataEnvelope( + credentials: credentials, + chainId: eip7702Data.domain.chainId, + jsonEnvelope: jsonEncode(typedDataMap), + ); + } + + /// KYC standalone sign (future NEW-19 path). Exercised by the + /// pipeline tests today; production callsite lands when NEW-19 PII + /// migration ships. + Future signKycEnvelope({ + required CredentialsWithKnownAddress credentials, + required int chainId, + required String verifyingContract, + required String accountType, + required String firstName, + required String lastName, + required String phone, + required String addressStreet, + required String addressHouseNumber, + required String addressZip, + required String addressCity, + required int addressCountry, + required String registrationDate, + Eip712Schema schema = const KycSignSchema(), + }) { + final Map typedDataMap = { + 'types': schema.typesAsJson(), + 'primaryType': schema.primaryType, + 'domain': { + 'name': 'RealUnitKyc', + 'version': '1', + 'chainId': chainId, + 'verifyingContract': verifyingContract, + }, + 'message': { + 'accountType': accountType, + 'firstName': firstName, + 'lastName': lastName, + 'phone': phone, + 'addressStreet': addressStreet, + 'addressHouseNumber': addressHouseNumber, + 'addressZip': addressZip, + 'addressCity': addressCity, + 'addressCountry': addressCountry, + 'walletAddress': credentials.address.hexEip55, + 'registrationDate': registrationDate, + }, + }; + return signTypedDataEnvelope( + credentials: credentials, + chainId: chainId, + jsonEnvelope: jsonEncode(typedDataMap), + ); } - static Future _signTypedData( - CredentialsWithKnownAddress credentials, - int chainId, - String jsonData, - ) async { + /// Low-level entrypoint — signs an arbitrary JSON typed-data envelope + /// using the supplied [credentials]. Exposed so [SignPipeline] can + /// build its own envelopes via the schema constants and submit them + /// through a single (testable) seam. + Future signTypedDataEnvelope({ + required CredentialsWithKnownAddress credentials, + required int chainId, + required String jsonEnvelope, + }) async { final signature = await switch (credentials) { - BitboxCredentials() => credentials.signTypedDataV4(chainId, jsonData), + BitboxCredentials() => credentials.signTypedDataV4(chainId, jsonEnvelope), EthPrivateKey() => Future.value( EthSigUtil.signTypedData( privateKey: bytesToHex(credentials.privateKey, include0x: true), - jsonData: jsonData, + jsonData: jsonEnvelope, version: TypedDataVersion.V4, ), ), @@ -131,4 +331,101 @@ class Eip712Signer { } return signature; } + + // ------------------------------------------------------------------------ + // Backward-compat static wrappers + // + // Call sites in `RealUnitRegistrationService` and + // `RealUnitSellPaymentInfoService` still use the static entry points; + // migrating those services to the [SignPipeline] is tracked separately. + // The legacy `signRegistration` static keeps the V0 (no chainId in + // domain) signature to remain bit-identical with what the production + // backend currently expects. Once the backend coordination for V1 + // lands, callsites switch to the pipeline and these wrappers are + // removed. + // ------------------------------------------------------------------------ + + static Future signRegistration({ + required CredentialsWithKnownAddress credentials, + required int chainId, + required String email, + required String name, + required String type, + required String phoneNumber, + required String birthday, + required String nationality, + required String addressStreet, + required String addressPostalCode, + required String addressCity, + required String addressCountry, + required bool swissTaxResidence, + required String registrationDate, + }) { + return const Eip712Signer().signRegistrationEnvelope( + credentials: credentials, + chainId: chainId, + email: email, + name: name, + type: type, + phoneNumber: phoneNumber, + birthday: birthday, + nationality: nationality, + addressStreet: addressStreet, + addressPostalCode: addressPostalCode, + addressCity: addressCity, + addressCountry: addressCountry, + swissTaxResidence: swissTaxResidence, + registrationDate: registrationDate, + schema: const RegistrationSchemaV0(), + ); + } + + static Future signDelegation({ + required CredentialsWithKnownAddress credentials, + required Eip7702Data eip7702Data, + }) { + // Legacy static delegation has no expected-params validation — the + // caller side (real_unit_sell_payment_info_service.dart:: + // _validateEip7702Data) still does that until the migration to the + // pipeline lands. The pipeline/instance method is the canonical + // surface for new callers. + final signer = const Eip712Signer(); + final Map typedDataMap = { + 'types': { + 'EIP712Domain': [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + {'name': 'chainId', 'type': 'uint256'}, + {'name': 'verifyingContract', 'type': 'address'}, + ], + 'Delegation': eip7702Data.types.delegation + .map((field) => {'name': field.name, 'type': field.type}) + .toList(), + 'Caveat': eip7702Data.types.caveat + .map((field) => {'name': field.name, 'type': field.type}) + .toList(), + }, + 'primaryType': 'Delegation', + 'domain': { + 'name': eip7702Data.domain.name, + 'version': eip7702Data.domain.version, + 'chainId': eip7702Data.domain.chainId, + 'verifyingContract': eip7702Data.domain.verifyingContract, + }, + 'message': { + 'delegate': eip7702Data.message.delegate, + 'delegator': eip7702Data.message.delegator, + 'authority': eip7702Data.message.authority, + 'caveats': eip7702Data.message.caveats, + // uint256 → decimal string; the typed-data encoder reads it identically + // to a JSON number, and a BigInt is not JSON-encodable. + 'salt': eip7702Data.message.salt.toString(), + }, + }; + return signer.signTypedDataEnvelope( + credentials: credentials, + chainId: eip7702Data.domain.chainId, + jsonEnvelope: jsonEncode(typedDataMap), + ); + } } diff --git a/lib/packages/wallet/eip7702_signer.dart b/lib/packages/wallet/eip7702_signer.dart index be38953a..0382eb8d 100644 --- a/lib/packages/wallet/eip7702_signer.dart +++ b/lib/packages/wallet/eip7702_signer.dart @@ -17,7 +17,7 @@ class Eip7702Signer { final eip7702.UnsignedAuthorization unsignedAuth = ( chainId: BigInt.from(eip7702Data.domain.chainId), delegateAddress: eip7702Data.delegatorAddress, - nonce: BigInt.from(eip7702Data.userNonce), + nonce: eip7702Data.userNonce, ); final signer = eip7702.Signer.eth(credentials); final authTuple = eip7702.signAuthorization(signer, unsignedAuth); diff --git a/lib/packages/wallet/error_mapper.dart b/lib/packages/wallet/error_mapper.dart new file mode 100644 index 00000000..a1f5e696 --- /dev/null +++ b/lib/packages/wallet/error_mapper.dart @@ -0,0 +1,362 @@ +// Typed-exception hierarchy + mapping for the SignPipeline. +// +// Why this file exists: +// F-003 / F-016 / F-020 / F-021 in the 2026-05-23 audit identified the same +// failure mode in multiple cubits — `catch (e) { e.toString() }` to route +// a user-visible error. Any refactor that renames the underlying exception +// type silently drops the special handling and the user sees a generic +// "registration failed". The audit's recommendation: +// +// "Every BitBox SDK error code maps to a typed Dart exception with an +// i18n key — operationalised by Initiative II." +// +// This file is the implementation: +// * one base class `SignException` (declared in `exceptions/sign_exception.dart`) +// * one typed subclass per code path the pipeline can throw +// * one [ErrorMapper] that turns a raw cause (native error code, Object +// from a catch site, native message) into a typed [SignException] +// * an exhaustive test in `test/packages/wallet/error_mapper_test.dart` +// fails the build if a new code is added without a typed exception. +// +// The ARB keys named below MUST exist in `assets/languages/strings_de.arb` +// and `_en.arb`; the exhaustive test asserts presence. + +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/sign_exception.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/btc_psbt_schema.dart'; + +export 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; +export 'package:realunit_wallet/packages/wallet/exceptions/sign_exception.dart'; +export 'package:realunit_wallet/packages/wallet/schemas/btc_psbt_schema.dart' show BtcPsbtInvalidException; + +// ------------------------------------------------------------------------ +// BitBox-side typed exceptions. +// +// The native BitBox SDK surfaces error codes (101 = `ErrInvalidInput`, +// etc.). The mapping table below converts each known code into a typed +// Dart exception. Unknown codes fall through to BitboxUnknownException — +// preserving the raw code lets support triage a new firmware error +// without crashing the cubit. +// ------------------------------------------------------------------------ + +/// 101 = `ErrInvalidInput` — the device refused the request because a +/// field violates its content rules (e.g. non-ASCII in an EIP-712 string, +/// payload too long). +class BitboxInvalidInputException extends SignException { + final String? detail; + const BitboxInvalidInputException({this.detail}); + @override + String get arbKey => 'errorBitboxInvalidInput'; + @override + String toString() => 'BitboxInvalidInputException(${detail ?? ""})'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BitboxInvalidInputException && other.detail == detail); + @override + int get hashCode => detail.hashCode; +} + +/// 102 = `ErrUserAbort` — the user pressed cancel on the device. +class BitboxUserAbortException extends SignException { + const BitboxUserAbortException(); + @override + String get arbKey => 'errorBitboxUserAbort'; + @override + String toString() => 'BitboxUserAbortException'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is BitboxUserAbortException; + @override + int get hashCode => (BitboxUserAbortException).hashCode; +} + +/// 103 = channel hash mismatch — pairing channel-hash verify returned false. +class BitboxChannelHashMismatchException extends SignException { + const BitboxChannelHashMismatchException(); + @override + String get arbKey => 'errorBitboxChannelHashMismatch'; + @override + String toString() => 'BitboxChannelHashMismatchException'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is BitboxChannelHashMismatchException; + @override + int get hashCode => (BitboxChannelHashMismatchException).hashCode; +} + +/// 104 = native side ran the BitBox SDK's transport timeout — surfaces as +/// a typed Dart exception so cubits can react with a reconnect prompt +/// rather than a generic failure. +class BitboxTimeoutException extends SignException { + const BitboxTimeoutException(); + @override + String get arbKey => 'errorBitboxTimeout'; + @override + String toString() => 'BitboxTimeoutException'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is BitboxTimeoutException; + @override + int get hashCode => (BitboxTimeoutException).hashCode; +} + +/// Re-export of [BitboxNotConnectedException] under the [SignException] +/// umbrella so cubits can switch on `SignException` rather than juggling +/// two hierarchies. The original class lives in +/// `lib/packages/service/dfx/exceptions/bitbox_exception.dart` for +/// historical reasons; we wrap it. +class BitboxNotConnectedSignException extends SignException { + const BitboxNotConnectedSignException(); + @override + String get arbKey => 'errorBitboxNotConnected'; + @override + String toString() => 'BitboxNotConnectedSignException'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is BitboxNotConnectedSignException; + @override + int get hashCode => (BitboxNotConnectedSignException).hashCode; +} + +/// Catch-all for native BitBox error codes the mapper does not yet know. +/// Carries the raw code so support has enough context to triage. +class BitboxUnknownException extends SignException { + final int rawCode; + final String? message; + const BitboxUnknownException(this.rawCode, {this.message}); + @override + String get arbKey => 'errorBitboxUnknown'; + @override + String toString() => 'BitboxUnknownException(code=$rawCode, message=$message)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BitboxUnknownException && other.rawCode == rawCode && other.message == message); + @override + int get hashCode => Object.hash(rawCode, message); +} + +// ------------------------------------------------------------------------ +// Pipeline-side typed exceptions (non-BitBox). +// ------------------------------------------------------------------------ + +/// Raised when the device does not support EIP-7702 (older firmware). +class Eip7702NotSupportedException extends SignException { + const Eip7702NotSupportedException(); + @override + String get arbKey => 'errorEip7702NotSupported'; + @override + String toString() => 'Eip7702NotSupportedException'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is Eip7702NotSupportedException; + @override + int get hashCode => (Eip7702NotSupportedException).hashCode; +} + +/// Raised when a payload labelled `isEIP1559: true` does NOT actually +/// start with the EIP-2718 type byte `0x02` — closes F-040. Without this +/// guard `signToSignature` would silently strip the first byte and the +/// device would sign a corrupted payload. +class Eip1559TypeMismatchException extends SignException { + final int? actualByte; + const Eip1559TypeMismatchException({this.actualByte}); + @override + String get arbKey => 'errorEip1559TypeMismatch'; + @override + String toString() { + final byte = actualByte == null ? 'null' : '0x${actualByte!.toRadixString(16)}'; + return 'Eip1559TypeMismatchException(payload[0]=$byte, expected=0x02)'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Eip1559TypeMismatchException && other.actualByte == actualByte); + @override + int get hashCode => actualByte.hashCode; +} + +/// Raised when an EIP-7702 sell payload's expected pinned parameters +/// (verifyingContract / chainId / delegator / amount) do not match the +/// backend response — closes F-039. The pinning lives inside the signer +/// rather than in the caller (sell-service) so a future caller cannot +/// forget the validation step. +class Eip7702ExpectedParamsMismatchException extends SignException { + final String parameter; + final String expected; + final String actual; + const Eip7702ExpectedParamsMismatchException({ + required this.parameter, + required this.expected, + required this.actual, + }); + @override + String get arbKey => 'errorEip7702ExpectedParamsMismatch'; + @override + String toString() => + 'Eip7702ExpectedParamsMismatchException(parameter=$parameter, ' + 'expected=$expected, actual=$actual)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Eip7702ExpectedParamsMismatchException && + other.parameter == parameter && + other.expected == expected && + other.actual == actual); + @override + int get hashCode => Object.hash(parameter, expected, actual); +} + +/// Raised when a SignRequest's preconditions fail (empty required field, +/// invalid country symbol, missing wallet address, etc.). Carries the +/// field name for diagnostics; cubits use the ARB key. +class SignRequestValidationException extends SignException { + final String field; + final String reason; + const SignRequestValidationException({required this.field, required this.reason}); + @override + String get arbKey => 'errorSignRequestInvalid'; + @override + String toString() => 'SignRequestValidationException(field=$field, reason=$reason)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SignRequestValidationException && + other.field == field && + other.reason == reason); + @override + int get hashCode => Object.hash(field, reason); +} + +/// Raised when the user cancels the sign on the device (BitBox returns +/// empty signature `0x`) — kept as a typed `SignException` so cubits can +/// distinguish "user cancelled" from "device disconnected". +class SigningCancelledSignException extends SignException { + const SigningCancelledSignException(); + @override + String get arbKey => 'errorSigningCancelled'; + @override + String toString() => 'SigningCancelledSignException'; + + @override + bool operator ==(Object other) => + identical(this, other) || other is SigningCancelledSignException; + @override + int get hashCode => (SigningCancelledSignException).hashCode; +} + +// ------------------------------------------------------------------------ +// ErrorMapper: the single boundary that turns raw causes into SignException. +// ------------------------------------------------------------------------ + +class ErrorMapper { + const ErrorMapper(); + + /// Known BitBox-side native error codes. Centralising them here means + /// the exhaustive test in `error_mapper_test.dart` can iterate the + /// table — if a new firmware error code is observed in production but + /// not added here, the test stays green only because the mapper + /// returned `BitboxUnknownException(code)`. Once a code is added to + /// `knownCodes`, the test asserts a typed (non-unknown) mapping. + static const knownCodes = {101, 102, 103, 104}; + + /// Maps a native BitBox error code to a typed [SignException]. + /// + /// Codes are documented in `bitbox_flutter` `go/api/api.go` (mirrored + /// from the upstream `bitbox02-api-go` source). The codes below are the + /// stable subset observed in production: + /// + /// - 101 `ErrInvalidInput` → [BitboxInvalidInputException] + /// - 102 `ErrUserAbort` → [BitboxUserAbortException] + /// - 103 channel hash → [BitboxChannelHashMismatchException] + /// - 104 transport timeout → [BitboxTimeoutException] + /// + /// Anything else surfaces as [BitboxUnknownException] with the raw code + /// preserved. + SignException mapBitboxCode(int code, {String? message}) { + switch (code) { + case 101: + return BitboxInvalidInputException(detail: message); + case 102: + return const BitboxUserAbortException(); + case 103: + return const BitboxChannelHashMismatchException(); + case 104: + return const BitboxTimeoutException(); + default: + return BitboxUnknownException(code, message: message); + } + } + + /// Turns an arbitrary [cause] caught in the pipeline into a + /// [SignException]. The conversion is exhaustive over the known cause + /// hierarchy: + /// + /// - already a [SignException] → returned as-is + /// - [SigningCancelledException] (legacy) → [SigningCancelledSignException] + /// - [BitboxNotConnectedException] (legacy) → [BitboxNotConnectedSignException] + /// + /// Anything else becomes a [BitboxUnknownException] with rawCode = -1 + /// and the original `toString()` as the message — preserving the cause + /// for telemetry without leaking it into the user-visible string. + SignException mapCause(Object cause) { + if (cause is SignException) return cause; + if (cause is SigningCancelledException) { + return const SigningCancelledSignException(); + } + if (cause is BitboxNotConnectedException) { + return const BitboxNotConnectedSignException(); + } + return BitboxUnknownException(-1, message: cause.toString()); + } +} + +// ------------------------------------------------------------------------ +// Bookkeeping: the canonical list of every concrete SignException class. +// Used by the exhaustive ErrorMapper test to assert (a) every class has a +// non-empty ARB key, (b) every key is present in BOTH `strings_*.arb` +// files, (c) no key is shared between two classes (avoid copy-paste +// collisions). +// ------------------------------------------------------------------------ + +/// Helper for the exhaustiveness test to enumerate every typed exception +/// the pipeline can emit. New typed exceptions MUST be added here so the +/// test can assert their ARB key exists in both languages. +List allKnownSignExceptions() { + return const [ + BitboxInvalidInputException(), + BitboxUserAbortException(), + BitboxChannelHashMismatchException(), + BitboxTimeoutException(), + BitboxNotConnectedSignException(), + BitboxUnknownException(0), + Eip712SchemaDriftException( + driftedField: 'Delegation[0]', + schemaVersion: 'eip7702-delegation/v1', + reason: 'extra field', + ), + Eip7702NotSupportedException(), + Eip1559TypeMismatchException(), + Eip7702ExpectedParamsMismatchException( + parameter: 'chainId', + expected: '1', + actual: '5', + ), + SignRequestValidationException(field: 'email', reason: 'empty'), + SigningCancelledSignException(), + BtcPsbtInvalidException('empty'), + ]; +} diff --git a/lib/packages/wallet/exceptions/eip712_schema_drift_exception.dart b/lib/packages/wallet/exceptions/eip712_schema_drift_exception.dart new file mode 100644 index 00000000..db426e4c --- /dev/null +++ b/lib/packages/wallet/exceptions/eip712_schema_drift_exception.dart @@ -0,0 +1,48 @@ +// Forward-declared schema-drift exception. +// +// Kept in its own file because `Eip712Schema.validate` needs to throw it, +// and `ErrorMapper` needs to import it as part of the typed `SignException` +// hierarchy. Defining it in either location alone would create an import +// cycle through `error_mapper.dart`. Re-exported from there for callers +// that already import the ErrorMapper. + +import 'package:realunit_wallet/packages/wallet/exceptions/sign_exception.dart'; + +/// Raised when a backend-supplied EIP-712 `types` map deviates from the +/// client-pinned [Eip712Schema] constant — the central defence against +/// F-038 / F-039 (Initiative II). +/// +/// [driftedField] points at the first deviation found (e.g. `Delegation[3].type` +/// or `Caveat`). [schemaVersion] identifies which client schema rejected +/// the response so the journal entry has enough context to plan the +/// migration. [reason] is a short human-readable description; consumers +/// should NOT pattern-match on it (it is a debug aid, not an API). +class Eip712SchemaDriftException extends SignException { + final String driftedField; + final String schemaVersion; + final String reason; + + const Eip712SchemaDriftException({ + required this.driftedField, + required this.schemaVersion, + required this.reason, + }); + + @override + String get arbKey => 'errorEip712SchemaDrift'; + + @override + String toString() => + 'Eip712SchemaDriftException(field=$driftedField, schema=$schemaVersion, reason=$reason)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Eip712SchemaDriftException && + other.driftedField == driftedField && + other.schemaVersion == schemaVersion && + other.reason == reason); + + @override + int get hashCode => Object.hash(driftedField, schemaVersion, reason); +} diff --git a/lib/packages/wallet/exceptions/sign_exception.dart b/lib/packages/wallet/exceptions/sign_exception.dart new file mode 100644 index 00000000..0dbb981b --- /dev/null +++ b/lib/packages/wallet/exceptions/sign_exception.dart @@ -0,0 +1,28 @@ +// Typed exception hierarchy root for the SignPipeline. +// +// Every error path that can come out of the pipeline (validation, +// romanisation, schema-drift, BitBox plugin, EIP-1559 type-byte mismatch) +// is a [SignException] subclass with an [arbKey] string. Cubits switch on +// the type and use [arbKey] to fetch the user-visible string from i18n — +// no `e.toString()` string-matching anywhere (the cause of F-016 / F-020 / +// F-021 etc.). +// +// Why `abstract class` and not `sealed`: Dart 3 sealed classes require all +// subclasses in the same library. The BitBox-side typed exceptions and the +// schema-drift exception live in `exceptions/` for import-graph reasons, +// while the ErrorMapper consolidates them; sealed would force a single +// file and we explicitly chose layered files. The exhaustiveness contract +// is enforced by the `ErrorMapper`'s exhaustive-test, not by the language. + +/// Base of the SignPipeline typed exception hierarchy. +abstract class SignException implements Exception { + const SignException(); + + /// i18n ARB key used by cubits to render the user-visible message. + /// + /// Convention: keys live under `strings_*.arb` namespaced as + /// `errorBitbox*` / `errorEip712*` / `errorEip7702*` / `errorEip1559*`. + /// The exhaustive ErrorMapper test asserts every concrete subclass has a + /// non-empty ARB key — see `test/packages/wallet/error_mapper_test.dart`. + String get arbKey; +} diff --git a/lib/packages/wallet/schemas/btc_psbt_schema.dart b/lib/packages/wallet/schemas/btc_psbt_schema.dart new file mode 100644 index 00000000..91fb0dc0 --- /dev/null +++ b/lib/packages/wallet/schemas/btc_psbt_schema.dart @@ -0,0 +1,95 @@ +// Client-pinned schema for BTC PSBT signing. +// +// PSBT (BIP-174) is NOT an EIP-712 typed-data envelope — the BitBox firmware +// signs raw PSBT bytes via the BIP-174 protocol. There is no `types` map +// to compare. We still wrap it in a schema class so: +// +// 1. The pipeline has a uniform `SignRequest → Schema → SignResult` +// contract for all six entrypoints. +// 2. The PSBT version + `bitbox_flutter` quirk-version pin lives next to +// the other schemas — a future PSBT v2 / Schnorr / Taproot rollout +// bumps `schemaVersion` and the testkit scenarios that pin +// `BtcPsbtMultiInputSign` know which version they exercise. +// 3. The pipeline can reject empty / oversized / wrong-magic-byte PSBTs +// before they ever reach the BitBox plugin — same fail-fast philosophy +// as the EIP-712 byte-equal compare. +// +// The base `Eip712Schema.validate` is bypassed for PSBT (no types map), +// so this schema exposes a separate `validatePsbt(Uint8List)` helper. +// Callers MUST NOT use the inherited `validate(Map)` — see assertion in the +// override below. + +import 'dart:typed_data'; + +import 'package:realunit_wallet/packages/wallet/exceptions/sign_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; + +/// Raised when a PSBT byte payload fails the structural / magic-byte +/// pre-flight before reaching the BitBox plugin. +class BtcPsbtInvalidException extends SignException { + final String reason; + const BtcPsbtInvalidException(this.reason); + @override + String get arbKey => 'errorBitboxBtcPsbtInvalid'; + @override + String toString() => 'BtcPsbtInvalidException($reason)'; + + @override + bool operator ==(Object other) => + identical(this, other) || (other is BtcPsbtInvalidException && other.reason == reason); + @override + int get hashCode => reason.hashCode; +} + +class BtcPsbtSchema extends Eip712Schema { + const BtcPsbtSchema(); + + @override + String get schemaVersion => 'btc-psbt/v1'; + + /// PSBTs have no EIP-712 primary type; we expose the protocol name so + /// logs/journal entries are unambiguous. + @override + String get primaryType => 'BTC_PSBT'; + + /// PSBTs carry no EIP-712 `types` map. Inheritors use [validatePsbt] + /// instead of `validate(Map)`. + @override + Map> get types => const {}; + + /// Always throws — PSBT does not have a typed-data envelope. Callers + /// must use [validatePsbt] instead. Documented as a runtime invariant + /// rather than removed entirely so the inherited class hierarchy stays + /// uniform across the six entrypoints. + @override + void validate(Map backendTypes) { + throw StateError( + 'BtcPsbtSchema.validate(Map) is invalid; PSBT has no typed-data envelope. ' + 'Use validatePsbt(Uint8List) instead.', + ); + } + + /// PSBT pre-flight: rejects empty / clearly-malformed inputs before they + /// reach the BitBox plugin. + /// + /// PSBT magic bytes per BIP-174: `psbt\xff` (`0x70 0x73 0x62 0x74 0xff`). + /// This is the minimum sanity check; the BitBox firmware performs the + /// full BIP-174 parse on its side. + void validatePsbt(Uint8List psbtBytes) { + if (psbtBytes.isEmpty) { + throw const BtcPsbtInvalidException('PSBT payload is empty'); + } + if (psbtBytes.length < 5) { + throw const BtcPsbtInvalidException('PSBT payload shorter than magic bytes'); + } + const magic = [0x70, 0x73, 0x62, 0x74, 0xff]; + for (var i = 0; i < 5; i++) { + if (psbtBytes[i] != magic[i]) { + throw BtcPsbtInvalidException( + 'PSBT magic-byte mismatch at offset $i: ' + 'got 0x${psbtBytes[i].toRadixString(16)}, expected 0x${magic[i].toRadixString(16)}', + ); + } + } + } +} diff --git a/lib/packages/wallet/schemas/eip712_schema.dart b/lib/packages/wallet/schemas/eip712_schema.dart new file mode 100644 index 00000000..9a6804bf --- /dev/null +++ b/lib/packages/wallet/schemas/eip712_schema.dart @@ -0,0 +1,179 @@ +// EIP-712 schema base class. +// +// A schema is a compile-time constant description of the typed-data fields +// the client is willing to sign. The pipeline compares the backend-supplied +// `types` map against this constant **byte-equal** before any sign byte +// reaches the BitBox plugin. +// +// Why byte-equal and not "structural": F-038 (Initiative II) — a malicious +// backend could add a hidden field, reorder fields, swap types, or rename a +// field while keeping the visible message intact. The user would never see +// the extra field in the validate-UI, the BitBox would sign it anyway, and +// the operator would be stuck with a signature over an envelope they cannot +// re-derive. The only safe contract is: the client signs ONLY shapes it has +// explicitly approved in source. Any deviation is rejected up front. +// +// Equality semantics: +// - Field order matters. EIP-712 hashes the type string left-to-right; two +// field lists with the same names but reordered produce different hashes. +// - Field names matter. A typo `delgate` vs `delegate` is a different type +// and must drift-reject. +// - Field types matter. A backend that switches `uint256` to `int256` for +// a numeric field is a schema mismatch. +// - Top-level type-group names matter. `'Delegation'` vs `'delegation'` is +// a different primary type. +// - Extra top-level groups in the backend response (not just within a +// group) also drift. The client schema is the trust root; anything else +// is foreign. + +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; + +/// One named field in a typed-data type group: `{name, type}`. +class Eip712FieldSpec { + final String name; + final String type; + const Eip712FieldSpec(this.name, this.type); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Eip712FieldSpec && other.name == name && other.type == type); + + @override + int get hashCode => Object.hash(name, type); + + @override + String toString() => '{$name: $type}'; +} + +/// Base class for client-pinned EIP-712 schemas. +/// +/// Subclasses are `const` and expose: +/// * a [schemaVersion] string for journal entries (`v1` / `v2` migrations) +/// * a [primaryType] (e.g. `RealUnitUser`, `Delegation`) +/// * a [types] map keyed by EIP-712 type-group name. Every value is the +/// in-order list of `{name, type}` field specs. +/// +/// The [validate] entrypoint compares a backend-supplied `types` map against +/// the constant and throws [Eip712SchemaDriftException] on any deviation. +abstract class Eip712Schema { + const Eip712Schema(); + + String get schemaVersion; + String get primaryType; + + /// Client-pinned type groups. Implementations return a `const` map. + Map> get types; + + /// Re-emits the pinned [types] in the wire format the eth_sig_util + /// V4 signer expects: a `Map>>`. + /// + /// Centralising this means callsites build the typed-data envelope from + /// the **schema constant**, not from the backend response — closes F-038 + /// at the construction site, not just the validate site. + Map>> typesAsJson() { + return { + for (final entry in types.entries) + entry.key: [ + for (final field in entry.value) {'name': field.name, 'type': field.type}, + ], + }; + } + + /// Throws [Eip712SchemaDriftException] when [backendTypes] does NOT match + /// the pinned [types] byte-equal (order-sensitive, name-sensitive, + /// type-sensitive, top-level-name-sensitive). + /// + /// [backendTypes] is the raw map decoded from the backend response. The + /// type-group lists may contain `Map` or + /// `Map`; both shapes are accepted as long as each entry + /// has exactly `name` and `type` keys with string values. + void validate(Map backendTypes) { + // Top-level groups must match by name (order-insensitive on the group + // level; only field order inside a group matters for EIP-712 hashing). + final pinnedGroups = types.keys.toSet(); + final backendGroups = backendTypes.keys.toSet(); + + final extra = backendGroups.difference(pinnedGroups); + if (extra.isNotEmpty) { + throw Eip712SchemaDriftException( + driftedField: extra.first, + schemaVersion: schemaVersion, + reason: 'extra type group: ${extra.first}', + ); + } + final missing = pinnedGroups.difference(backendGroups); + if (missing.isNotEmpty) { + throw Eip712SchemaDriftException( + driftedField: missing.first, + schemaVersion: schemaVersion, + reason: 'missing type group: ${missing.first}', + ); + } + + for (final group in types.keys) { + final pinned = types[group]!; + final raw = backendTypes[group]; + if (raw is! List) { + throw Eip712SchemaDriftException( + driftedField: group, + schemaVersion: schemaVersion, + reason: 'type group "$group" is not a list', + ); + } + if (raw.length != pinned.length) { + throw Eip712SchemaDriftException( + driftedField: group, + schemaVersion: schemaVersion, + reason: + 'type group "$group" has ${raw.length} fields, expected ${pinned.length}', + ); + } + for (var i = 0; i < pinned.length; i++) { + final entry = raw[i]; + if (entry is! Map) { + throw Eip712SchemaDriftException( + driftedField: '$group[$i]', + schemaVersion: schemaVersion, + reason: 'field $i in "$group" is not a {name,type} map', + ); + } + final name = entry['name']; + final type = entry['type']; + if (name is! String || type is! String) { + throw Eip712SchemaDriftException( + driftedField: '$group[$i]', + schemaVersion: schemaVersion, + reason: 'field $i in "$group" has non-string name/type', + ); + } + if (entry.length != 2) { + // Extra keys (e.g. `internalType` from solc output) would change + // the JSON the backend signs but be invisible in the visible + // envelope. Refuse anything beyond exactly {name, type}. + throw Eip712SchemaDriftException( + driftedField: '$group[$i].${entry.keys.where((k) => k != 'name' && k != 'type').first}', + schemaVersion: schemaVersion, + reason: 'field $i in "$group" has extra keys beyond {name,type}', + ); + } + final expected = pinned[i]; + if (name != expected.name) { + throw Eip712SchemaDriftException( + driftedField: '$group[$i].name', + schemaVersion: schemaVersion, + reason: 'field $i in "$group" name "$name" != pinned "${expected.name}"', + ); + } + if (type != expected.type) { + throw Eip712SchemaDriftException( + driftedField: '$group[$i].type', + schemaVersion: schemaVersion, + reason: + 'field "$name" in "$group" type "$type" != pinned "${expected.type}"', + ); + } + } + } + } +} diff --git a/lib/packages/wallet/schemas/eip7702_delegation_schema.dart b/lib/packages/wallet/schemas/eip7702_delegation_schema.dart new file mode 100644 index 00000000..4f9661a8 --- /dev/null +++ b/lib/packages/wallet/schemas/eip7702_delegation_schema.dart @@ -0,0 +1,60 @@ +// Client-pinned schema for the EIP-7702 sell-delegation sign. +// +// Today the backend ships `eip7702Data.types.delegation` + `caveat` and the +// signer (`Eip712Signer.signDelegation`) rebuilds the typed-data map +// VERBATIM from those arrays. F-038 worst-case scenario: a malicious or +// MITM-ed backend adds `{name: "secretApproval", type: "uint256"}` to +// `delegation`; the user sees the visible amount in the validate-UI, taps +// sign, and the BitBox signs the smuggled field too. +// +// This schema is the trust root: the pipeline compares the backend-supplied +// `types` against this constant **byte-equal** and refuses to sign the +// envelope if there is any deviation. The validation logic uses the same +// `Eip712Schema.validate` comparator as the registration schema — see +// `eip712_schema.dart` for the per-cell semantics. +// +// `Delegation` type signature (MetaMask Delegation Framework v1.3.0): +// +// Delegation(address delegate, +// address delegator, +// bytes32 authority, +// Caveat[] caveats, +// uint256 salt) +// Caveat(address enforcer, +// bytes terms) +// +// Source: https://github.com/MetaMask/delegation-framework v1.3.0 +// (also documented in the testkit: §4.10 — Sell-EIP-7702 pre-flight). + +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; + +class Eip7702DelegationSchema extends Eip712Schema { + const Eip7702DelegationSchema(); + + @override + String get schemaVersion => 'eip7702-delegation/v1'; + + @override + String get primaryType => 'Delegation'; + + @override + Map> get types => const { + 'EIP712Domain': [ + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('version', 'string'), + Eip712FieldSpec('chainId', 'uint256'), + Eip712FieldSpec('verifyingContract', 'address'), + ], + 'Delegation': [ + Eip712FieldSpec('delegate', 'address'), + Eip712FieldSpec('delegator', 'address'), + Eip712FieldSpec('authority', 'bytes32'), + Eip712FieldSpec('caveats', 'Caveat[]'), + Eip712FieldSpec('salt', 'uint256'), + ], + 'Caveat': [ + Eip712FieldSpec('enforcer', 'address'), + Eip712FieldSpec('terms', 'bytes'), + ], + }; +} diff --git a/lib/packages/wallet/schemas/kyc_sign_schema.dart b/lib/packages/wallet/schemas/kyc_sign_schema.dart new file mode 100644 index 00000000..bc4f0181 --- /dev/null +++ b/lib/packages/wallet/schemas/kyc_sign_schema.dart @@ -0,0 +1,50 @@ +// Client-pinned schema for KYC-step typed-data signs. +// +// Today RealUnit does not run a separate `signKyc` call — KYC data is signed +// inside `signRegistration` (and intentionally also kept in the parallel +// `kycData` DTO sub-object with UTF-8 preserved for ID verification, see +// F-019). The `KycSignSchema` here is the structure the pipeline expects +// IF a future KYC-only sign step is added (the audit's NEW-19 PII-sig +// migration target). Pinning it now means the migration cannot ship without +// a matching schema entry and a backend-side rollout. +// +// Primary type `RealUnitKyc` with the personal-data envelope the API +// stores via `KycPersonalData` (lib/packages/service/dfx/models/registration +// /kyc/kyc_personal_data.dart). For the time being the schema's only +// production consumer is the test fixture proving the pipeline supports +// six entrypoints; the field set will be revisited when NEW-19 lands. + +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; + +class KycSignSchema extends Eip712Schema { + const KycSignSchema(); + + @override + String get schemaVersion => 'kyc/v1'; + + @override + String get primaryType => 'RealUnitKyc'; + + @override + Map> get types => const { + 'EIP712Domain': [ + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('version', 'string'), + Eip712FieldSpec('chainId', 'uint256'), + Eip712FieldSpec('verifyingContract', 'address'), + ], + 'RealUnitKyc': [ + Eip712FieldSpec('accountType', 'string'), + Eip712FieldSpec('firstName', 'string'), + Eip712FieldSpec('lastName', 'string'), + Eip712FieldSpec('phone', 'string'), + Eip712FieldSpec('addressStreet', 'string'), + Eip712FieldSpec('addressHouseNumber', 'string'), + Eip712FieldSpec('addressZip', 'string'), + Eip712FieldSpec('addressCity', 'string'), + Eip712FieldSpec('addressCountry', 'uint256'), + Eip712FieldSpec('walletAddress', 'address'), + Eip712FieldSpec('registrationDate', 'string'), + ], + }; +} diff --git a/lib/packages/wallet/schemas/registration_schema.dart b/lib/packages/wallet/schemas/registration_schema.dart new file mode 100644 index 00000000..c03fc994 --- /dev/null +++ b/lib/packages/wallet/schemas/registration_schema.dart @@ -0,0 +1,93 @@ +// Client-pinned schema for the EIP-712 RealUnit registration sign. +// +// V1 mirrors the current backend payload exactly (see +// `lib/packages/wallet/eip712_signer.dart::signRegistration` before the +// Initiative II refactor): +// +// primaryType: `RealUnitUser` +// fields: email, name, type, phoneNumber, birthday, nationality, +// addressStreet, addressPostalCode, addressCity, +// addressCountry, swissTaxResidence, registrationDate, +// walletAddress +// +// Domain (`EIP712Domain`) carries `name`, `version`, `chainId` (F-041 fix), +// and `verifyingContract` so registration signatures are +// chain-and-issuer-scoped. Backend rollout for `chainId`/`verifyingContract` +// is tracked in the Initiative II journal — until both endpoints accept the +// new domain bytes, the pipeline can fall back to a `name+version` schema +// via a v0 (non-pinned) bypass. The default schema for new clients is V1. + +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; + +class RegistrationSchemaV1 extends Eip712Schema { + const RegistrationSchemaV1(); + + @override + String get schemaVersion => 'registration/v1'; + + @override + String get primaryType => 'RealUnitUser'; + + @override + Map> get types => const { + 'EIP712Domain': [ + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('version', 'string'), + Eip712FieldSpec('chainId', 'uint256'), + Eip712FieldSpec('verifyingContract', 'address'), + ], + 'RealUnitUser': [ + Eip712FieldSpec('email', 'string'), + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('type', 'string'), + Eip712FieldSpec('phoneNumber', 'string'), + Eip712FieldSpec('birthday', 'string'), + Eip712FieldSpec('nationality', 'string'), + Eip712FieldSpec('addressStreet', 'string'), + Eip712FieldSpec('addressPostalCode', 'string'), + Eip712FieldSpec('addressCity', 'string'), + Eip712FieldSpec('addressCountry', 'string'), + Eip712FieldSpec('swissTaxResidence', 'bool'), + Eip712FieldSpec('registrationDate', 'string'), + Eip712FieldSpec('walletAddress', 'address'), + ], + }; +} + +/// Legacy `name + version` domain schema (no `chainId`, no +/// `verifyingContract`) — kept available for the backend-rollout window +/// where the production backend has not yet been upgraded to verify the +/// new domain. The pipeline picks this only when the SignRequest carries +/// an explicit `legacyDomain: true` flag. +class RegistrationSchemaV0 extends Eip712Schema { + const RegistrationSchemaV0(); + + @override + String get schemaVersion => 'registration/v0-legacy'; + + @override + String get primaryType => 'RealUnitUser'; + + @override + Map> get types => const { + 'EIP712Domain': [ + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('version', 'string'), + ], + 'RealUnitUser': [ + Eip712FieldSpec('email', 'string'), + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('type', 'string'), + Eip712FieldSpec('phoneNumber', 'string'), + Eip712FieldSpec('birthday', 'string'), + Eip712FieldSpec('nationality', 'string'), + Eip712FieldSpec('addressStreet', 'string'), + Eip712FieldSpec('addressPostalCode', 'string'), + Eip712FieldSpec('addressCity', 'string'), + Eip712FieldSpec('addressCountry', 'string'), + Eip712FieldSpec('swissTaxResidence', 'bool'), + Eip712FieldSpec('registrationDate', 'string'), + Eip712FieldSpec('walletAddress', 'address'), + ], + }; +} diff --git a/lib/packages/wallet/sign_pipeline.dart b/lib/packages/wallet/sign_pipeline.dart new file mode 100644 index 00000000..d6e81372 --- /dev/null +++ b/lib/packages/wallet/sign_pipeline.dart @@ -0,0 +1,731 @@ +// SignPipeline — the single Dart-side entry between a [SignRequest] and +// the BitBox plugin. +// +// Architectural goal (ADR 0002): every sign flow in the app — registration, +// re-register-wallet (KYC merge), sell EIP-7702 delegation, generic ETH +// transfer, future BTC PSBT, future KYC-only sign — funnels through the +// same five steps: +// +// _validate → _romanise → _pinSchema → _submitToBitbox → _mapResult +// +// What each step guarantees: +// +// * _validate pins the [SignRequest] shape (non-empty required +// fields, plausible chainId, etc.). Closes the +// swissTaxResidence/email/registrationDate "looks +// empty" leak class (F-002 / F-019). +// * _romanise runs [toBitboxSafeAscii] on EVERY user string of +// BOTH the envelope and the DTO so the signed bytes +// match the backend-stored bytes (F-019 closure). +// Returns a "romanised" copy of the request that is +// the single source of truth for everything below. +// * _pinSchema byte-equal compares any backend-supplied EIP-712 +// `types` map against the client-pinned schema +// constant. Extra/missing/reordered/wrong-type field +// raises [Eip712SchemaDriftException] **before** any +// byte reaches the BitBox (F-038 closure). +// * _submitToBitbox the sole callsite that hits the underlying +// [Eip712Signer] / [BitboxCredentials] plugin. +// * _mapResult catches anything the plugin throws and routes via +// [ErrorMapper] so the cubit always sees a typed +// [SignException]. +// +// Six entrypoints (sealed [SignRequest] hierarchy): +// +// RegistrationSignRequest, KycSignRequest, SellSignRequest, +// Eip7702SignRequest, BtcPsbtSignRequest, EthTransferSignRequest +// +// Each carries the parameters specific to that flow plus an explicit +// schema reference so the pinning step has a single constant to compare +// against. Property test in +// `test/packages/wallet/sign_pipeline_test.dart` asserts +// `pipeline(s).envelope == pipeline(s).dto` byte-equal post-romanise for +// every entrypoint. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/utils/ascii_transliterate.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/btc_psbt_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip7702_delegation_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/kyc_sign_schema.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/registration_schema.dart'; +import 'package:web3dart/crypto.dart'; +import 'package:web3dart/web3dart.dart'; + +// --------------------------------------------------------------------------- +// SignRequest hierarchy +// --------------------------------------------------------------------------- + +/// Tag-only superclass for the six pipeline entrypoints. +/// +/// Sealed-style: every concrete subclass lives in this file so the +/// pipeline's `switch (request)` statement is exhaustive at compile +/// time. A new entrypoint adds a new subclass here and a new switch +/// branch in [SignPipeline.sign]; the missing branch turns the analyzer +/// red. +sealed class SignRequest { + const SignRequest(); + + /// Credentials used to sign. For Tier-0 tests this is a + /// [FakeBitboxCredentials] or a raw [EthPrivateKey]; in production it is + /// the wallet's [primaryAddress]. + CredentialsWithKnownAddress get credentials; +} + +/// Registration / re-register-wallet sign. +/// +/// Carries the entire ASCII-safe field set the EIP-712 envelope +/// requires. The pipeline does NOT compute fields from raw form input — +/// the caller is responsible for supplying romanisable strings; the +/// pipeline guarantees the romanised view is used for BOTH the signed +/// envelope and the DTO. +class RegistrationSignRequest extends SignRequest { + @override + final CredentialsWithKnownAddress credentials; + final int chainId; + final String verifyingContract; + final String email; + final String name; + final String type; + final String phoneNumber; + final String birthday; + final String nationality; + final String addressStreet; + final String addressPostalCode; + final String addressCity; + final String addressCountry; + final bool swissTaxResidence; + + /// Server-issued timestamp (`yyyy-MM-dd`). The client never signs + /// `DateTime.now()` — F-042. Supplied by the backend in the + /// registration request so a jail-broken device clock cannot post-date + /// a sign. + final String registrationDate; + + /// Schema to pin against. Defaults to V1 (`chainId` + `verifyingContract` + /// in domain). Tests may inject a V0-legacy schema for the + /// backend-rollout window. + final Eip712Schema schema; + + const RegistrationSignRequest({ + required this.credentials, + required this.chainId, + required this.verifyingContract, + required this.email, + required this.name, + required this.type, + required this.phoneNumber, + required this.birthday, + required this.nationality, + required this.addressStreet, + required this.addressPostalCode, + required this.addressCity, + required this.addressCountry, + required this.swissTaxResidence, + required this.registrationDate, + this.schema = const RegistrationSchemaV1(), + }); +} + +/// Standalone KYC sign (future NEW-19 PII-sig migration). The schema +/// pinning is the same byte-equal compare; the wire format mirrors +/// [KycSignSchema]. +class KycSignRequest extends SignRequest { + @override + final CredentialsWithKnownAddress credentials; + final int chainId; + final String verifyingContract; + final String accountType; + final String firstName; + final String lastName; + final String phone; + final String addressStreet; + final String addressHouseNumber; + final String addressZip; + final String addressCity; + final int addressCountry; + final String registrationDate; + final Eip712Schema schema; + + const KycSignRequest({ + required this.credentials, + required this.chainId, + required this.verifyingContract, + required this.accountType, + required this.firstName, + required this.lastName, + required this.phone, + required this.addressStreet, + required this.addressHouseNumber, + required this.addressZip, + required this.addressCity, + required this.addressCountry, + required this.registrationDate, + this.schema = const KycSignSchema(), + }); +} + +/// EIP-7702 sell-delegation sign. The pipeline rejects the request if +/// any of the expected pinned parameters differ from the backend +/// response (F-039) — schema pinning lives inside the signer not in the +/// caller. +class Eip7702SignRequest extends SignRequest { + @override + final CredentialsWithKnownAddress credentials; + final Eip7702Data eip7702Data; + + /// Verifying contract the client expects in the EIP-712 domain + /// (i.e. the DelegationManager). A mismatch raises + /// [Eip7702ExpectedParamsMismatchException]. + final String expectedVerifyingContract; + + /// chainId the client expects in the EIP-712 domain. + final int expectedChainId; + + /// Delegator the client expects in `message.delegator` (the user's + /// wallet address, lowercased for the compare). + final String expectedDelegator; + + /// Sell amount the client expects in `amountWei`, as a [BigInt] in + /// wei units (decimals already applied by the caller). + final BigInt expectedAmount; + + final Eip7702DelegationSchema schema; + + const Eip7702SignRequest({ + required this.credentials, + required this.eip7702Data, + required this.expectedVerifyingContract, + required this.expectedChainId, + required this.expectedDelegator, + required this.expectedAmount, + this.schema = const Eip7702DelegationSchema(), + }); +} + +/// Sell sign — wraps an EIP-7702 sign for the production sell flow. +/// Distinct from [Eip7702SignRequest] only at the SignRequest type level +/// (so cubits can dispatch / log differently); pipeline behaviour is +/// identical. Kept separate per the ADR's "six entrypoints" contract. +class SellSignRequest extends Eip7702SignRequest { + const SellSignRequest({ + required super.credentials, + required super.eip7702Data, + required super.expectedVerifyingContract, + required super.expectedChainId, + required super.expectedDelegator, + required super.expectedAmount, + super.schema = const Eip7702DelegationSchema(), + }); +} + +/// BTC PSBT sign. Carries raw bytes; the pipeline runs +/// [BtcPsbtSchema.validatePsbt] as the pre-flight (magic bytes + length +/// sanity). The BitBox firmware then performs the full BIP-174 parse on +/// device. +class BtcPsbtSignRequest extends SignRequest { + @override + final CredentialsWithKnownAddress credentials; + final Uint8List psbtBytes; + final BtcPsbtSchema schema; + + const BtcPsbtSignRequest({ + required this.credentials, + required this.psbtBytes, + this.schema = const BtcPsbtSchema(), + }); +} + +/// Generic raw-payload ETH transfer sign (legacy or EIP-1559). The +/// pipeline asserts the `payload[0] == 0x02` type byte when +/// [isEIP1559] is `true` (F-040) before reaching the signer. +class EthTransferSignRequest extends SignRequest { + @override + final CredentialsWithKnownAddress credentials; + final Uint8List payload; + final int chainId; + final bool isEIP1559; + + const EthTransferSignRequest({ + required this.credentials, + required this.payload, + required this.chainId, + this.isEIP1559 = false, + }); +} + +// --------------------------------------------------------------------------- +// SignResult hierarchy +// --------------------------------------------------------------------------- + +/// Tag-only superclass for the pipeline outputs. Cubits switch on the +/// variant to extract the bytes (`signature` for typed-data sign, +/// `signedTx` for transfer sign). +sealed class SignResult { + const SignResult(); +} + +/// EIP-712 / EIP-7702 typed-data signature (hex-encoded with 0x +/// prefix). Used for registration, KYC, sell, EIP-7702 entrypoints. +class TypedDataSignResult extends SignResult { + final String signature; + + /// Envelope JSON the signature was produced over. Stored so callers + /// can persist it / compare against the DTO byte-equal in tests. + final String envelopeJson; + + /// DTO JSON sent to the backend (post-romanise, post-schema-pin). + final String dtoJson; + + const TypedDataSignResult({ + required this.signature, + required this.envelopeJson, + required this.dtoJson, + }); +} + +/// Raw transaction signature ([MsgSignature]). Used for the ETH transfer +/// entrypoint. +class EthTransferSignResult extends SignResult { + final MsgSignature signature; + const EthTransferSignResult(this.signature); +} + +/// PSBT placeholder — production implementation in Initiative III +/// scenarios; here we expose only the validated bytes so the rest of +/// the pipeline contract is exercised by tests today. +class BtcPsbtSignResult extends SignResult { + final Uint8List signedPsbt; + const BtcPsbtSignResult(this.signedPsbt); +} + +// --------------------------------------------------------------------------- +// SignPipeline +// --------------------------------------------------------------------------- + +/// Single Dart-side entry between a [SignRequest] and the BitBox +/// plugin. See file header for the architectural contract. +class SignPipeline { + /// EIP-712 signer used for typed-data flows. Injected so tests can + /// substitute a fake; production wires the real `Eip712Signer`. + final Eip712Signer eip712Signer; + + /// Error mapper used for the `catch` boundary. Configurable so tests + /// can substitute a mapper that records calls. + final ErrorMapper errorMapper; + + const SignPipeline({ + this.eip712Signer = const Eip712Signer(), + this.errorMapper = const ErrorMapper(), + }); + + /// Run a [SignRequest] through the pipeline. Returns the variant of + /// [SignResult] matching the request entrypoint. Throws a typed + /// [SignException] subclass on any failure — never an opaque + /// `Exception` / `Error` / `String`. + Future sign(SignRequest request) async { + try { + _validate(request); + final romanised = _romanise(request); + _pinSchema(romanised); + return await _submitToBitbox(romanised); + } on SignException { + // Already typed — let it propagate without re-wrapping (would + // lose the typed branch and force consumers to unwrap). + rethrow; + } on SigningCancelledException catch (e) { + throw errorMapper.mapCause(e); + } on BitboxNotConnectedException catch (e) { + throw errorMapper.mapCause(e); + } catch (e) { + // Any other throwable (e.g. a plugin returning a raw String, a + // FormatException from a malformed signature) is funnelled + // through the mapper so the cubit ALWAYS sees a typed + // [SignException] — closes the F-016 / F-020 / F-021 cluster + // (cubits doing `catch (e) { e.toString() }`). + throw errorMapper.mapCause(e); + } + } + + // ------------------------------------------------------------------------- + // _validate — field-presence + plausible-type contracts + // ------------------------------------------------------------------------- + + void _validate(SignRequest request) { + switch (request) { + case RegistrationSignRequest(): + _requireNonEmpty('email', request.email); + _requireNonEmpty('name', request.name); + _requireNonEmpty('type', request.type); + _requireNonEmpty('phoneNumber', request.phoneNumber); + _requireNonEmpty('birthday', request.birthday); + _requireNonEmpty('nationality', request.nationality); + _requireNonEmpty('addressStreet', request.addressStreet); + _requireNonEmpty('addressPostalCode', request.addressPostalCode); + _requireNonEmpty('addressCity', request.addressCity); + _requireNonEmpty('addressCountry', request.addressCountry); + _requireNonEmpty('registrationDate', request.registrationDate); + _requireNonEmpty('verifyingContract', request.verifyingContract); + _requirePositive('chainId', request.chainId); + case KycSignRequest(): + _requireNonEmpty('accountType', request.accountType); + _requireNonEmpty('firstName', request.firstName); + _requireNonEmpty('lastName', request.lastName); + _requireNonEmpty('phone', request.phone); + _requireNonEmpty('addressStreet', request.addressStreet); + _requireNonEmpty('addressHouseNumber', request.addressHouseNumber); + _requireNonEmpty('addressZip', request.addressZip); + _requireNonEmpty('addressCity', request.addressCity); + _requireNonEmpty('registrationDate', request.registrationDate); + _requireNonEmpty('verifyingContract', request.verifyingContract); + _requirePositive('chainId', request.chainId); + _requirePositive('addressCountry', request.addressCountry); + case Eip7702SignRequest(): + _requireNonEmpty('expectedVerifyingContract', request.expectedVerifyingContract); + _requireNonEmpty('expectedDelegator', request.expectedDelegator); + _requirePositive('expectedChainId', request.expectedChainId); + if (request.expectedAmount <= BigInt.zero) { + throw const SignRequestValidationException( + field: 'expectedAmount', + reason: 'expected amount must be positive wei', + ); + } + case BtcPsbtSignRequest(): + if (request.psbtBytes.isEmpty) { + throw const SignRequestValidationException( + field: 'psbtBytes', + reason: 'PSBT payload is empty', + ); + } + case EthTransferSignRequest(): + if (request.payload.isEmpty) { + throw const SignRequestValidationException( + field: 'payload', + reason: 'ETH transfer payload is empty', + ); + } + _requirePositive('chainId', request.chainId); + if (request.isEIP1559 && request.payload[0] != 0x02) { + // F-040 — refuse to strip the type byte unless it is actually + // the EIP-2718 `0x02` envelope. A caller that mislabels a + // legacy payload would otherwise sign a corrupted hash. + throw Eip1559TypeMismatchException(actualByte: request.payload[0]); + } + } + } + + void _requireNonEmpty(String field, String value) { + if (value.trim().isEmpty) { + throw SignRequestValidationException(field: field, reason: 'must not be empty'); + } + } + + void _requirePositive(String field, int value) { + if (value <= 0) { + throw SignRequestValidationException(field: field, reason: 'must be positive (>0)'); + } + } + + // ------------------------------------------------------------------------- + // _romanise — toBitboxSafeAscii on every user string + // ------------------------------------------------------------------------- + + SignRequest _romanise(SignRequest request) { + switch (request) { + case RegistrationSignRequest(): + return RegistrationSignRequest( + credentials: request.credentials, + chainId: request.chainId, + verifyingContract: request.verifyingContract, + email: toBitboxSafeAscii(request.email), + name: toBitboxSafeAscii(request.name), + type: toBitboxSafeAscii(request.type), + phoneNumber: toBitboxSafeAscii(request.phoneNumber), + birthday: toBitboxSafeAscii(request.birthday), + nationality: toBitboxSafeAscii(request.nationality), + addressStreet: toBitboxSafeAscii(request.addressStreet), + addressPostalCode: toBitboxSafeAscii(request.addressPostalCode), + addressCity: toBitboxSafeAscii(request.addressCity), + addressCountry: toBitboxSafeAscii(request.addressCountry), + swissTaxResidence: request.swissTaxResidence, + registrationDate: toBitboxSafeAscii(request.registrationDate), + schema: request.schema, + ); + case KycSignRequest(): + return KycSignRequest( + credentials: request.credentials, + chainId: request.chainId, + verifyingContract: request.verifyingContract, + accountType: toBitboxSafeAscii(request.accountType), + firstName: toBitboxSafeAscii(request.firstName), + lastName: toBitboxSafeAscii(request.lastName), + phone: toBitboxSafeAscii(request.phone), + addressStreet: toBitboxSafeAscii(request.addressStreet), + addressHouseNumber: toBitboxSafeAscii(request.addressHouseNumber), + addressZip: toBitboxSafeAscii(request.addressZip), + addressCity: toBitboxSafeAscii(request.addressCity), + addressCountry: request.addressCountry, + registrationDate: toBitboxSafeAscii(request.registrationDate), + schema: request.schema, + ); + case Eip7702SignRequest(): + // EIP-7702 fields are hex addresses + bytes — already ASCII. + // Romanise still runs idempotently for parity across entrypoints. + return request; + case BtcPsbtSignRequest(): + // PSBT bytes are not user strings. No-op. + return request; + case EthTransferSignRequest(): + // Raw transfer payload is bytes. No-op. + return request; + } + } + + // ------------------------------------------------------------------------- + // _pinSchema — byte-equal compare backend types against client constant + // ------------------------------------------------------------------------- + + void _pinSchema(SignRequest request) { + switch (request) { + case RegistrationSignRequest(): + // Registration constructs the envelope from the schema constant + // itself, so there is no backend-supplied `types` to compare. + // The schema reference still drives _submitToBitbox. + return; + case KycSignRequest(): + return; + case Eip7702SignRequest(): + _pinEip7702(request); + case BtcPsbtSignRequest(): + request.schema.validatePsbt(request.psbtBytes); + case EthTransferSignRequest(): + return; + } + } + + void _pinEip7702(Eip7702SignRequest request) { + final data = request.eip7702Data; + + // Compare expected pinned parameters first — F-039 closure. A + // mismatch on any of these is a hard reject; the backend has either + // moved or has been MITM-ed. + if (data.domain.verifyingContract.toLowerCase() != + request.expectedVerifyingContract.toLowerCase()) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'verifyingContract', + expected: request.expectedVerifyingContract, + actual: data.domain.verifyingContract, + ); + } + if (data.domain.chainId != request.expectedChainId) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'chainId', + expected: '${request.expectedChainId}', + actual: '${data.domain.chainId}', + ); + } + if (data.message.delegator.toLowerCase() != request.expectedDelegator.toLowerCase()) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'delegator', + expected: request.expectedDelegator, + actual: data.message.delegator, + ); + } + final actualWei = BigInt.tryParse(data.amountWei); + if (actualWei == null || actualWei != request.expectedAmount) { + throw Eip7702ExpectedParamsMismatchException( + parameter: 'amountWei', + expected: '${request.expectedAmount}', + actual: data.amountWei, + ); + } + + // Byte-equal compare the backend-supplied EIP-712 `types` against + // the client-pinned schema constant — F-038 closure. Build a + // canonical map from the DTO and hand it to the schema's + // [Eip712Schema.validate]; any extra / missing / reordered field + // raises [Eip712SchemaDriftException] before the BitBox sees a byte. + final backendTypes = { + 'EIP712Domain': const [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + {'name': 'chainId', 'type': 'uint256'}, + {'name': 'verifyingContract', 'type': 'address'}, + ], + 'Delegation': [ + for (final f in data.types.delegation) {'name': f.name, 'type': f.type}, + ], + 'Caveat': [ + for (final f in data.types.caveat) {'name': f.name, 'type': f.type}, + ], + }; + request.schema.validate(backendTypes); + } + + // ------------------------------------------------------------------------- + // _submitToBitbox — the sole callsite of the underlying plugin + // ------------------------------------------------------------------------- + + Future _submitToBitbox(SignRequest request) async { + switch (request) { + case RegistrationSignRequest(): + return _submitRegistration(request); + case KycSignRequest(): + return _submitKyc(request); + case Eip7702SignRequest(): + return _submitEip7702(request); + case BtcPsbtSignRequest(): + // TODO Initiative IV: route via WalletIsolate. For now the PSBT + // sign is wired through the existing BitboxCredentials path; the + // production BTC sign currently lives outside this pipeline. + return BtcPsbtSignResult(request.psbtBytes); + case EthTransferSignRequest(): + final sig = await request.credentials.signToSignature( + request.payload, + chainId: request.chainId, + isEIP1559: request.isEIP1559, + ); + return EthTransferSignResult(sig); + } + } + + Future _submitRegistration(RegistrationSignRequest r) async { + final message = { + 'email': r.email, + 'name': r.name, + 'type': r.type, + 'phoneNumber': r.phoneNumber, + 'birthday': r.birthday, + 'nationality': r.nationality, + 'addressStreet': r.addressStreet, + 'addressPostalCode': r.addressPostalCode, + 'addressCity': r.addressCity, + 'addressCountry': r.addressCountry, + 'swissTaxResidence': r.swissTaxResidence, + 'registrationDate': r.registrationDate, + 'walletAddress': r.credentials.address.hexEip55, + }; + final domain = _registrationDomain(r); + final typedDataMap = { + 'types': r.schema.typesAsJson(), + 'primaryType': r.schema.primaryType, + 'domain': domain, + 'message': message, + }; + final envelopeJson = jsonEncode(typedDataMap); + final dtoJson = jsonEncode(message); + final signature = await eip712Signer.signTypedDataEnvelope( + credentials: r.credentials, + chainId: r.chainId, + jsonEnvelope: envelopeJson, + ); + return TypedDataSignResult( + signature: signature, + envelopeJson: envelopeJson, + dtoJson: dtoJson, + ); + } + + Map _registrationDomain(RegistrationSignRequest r) { + // V1 domain includes chainId + verifyingContract; V0 has just + // name+version (the legacy backend-rollout window). Detect by the + // schema's EIP712Domain field list rather than a hard-coded type + // check, so injecting any future schema variant just works. + final hasChainId = r.schema.types['EIP712Domain']! + .any((f) => f.name == 'chainId'); + final hasVerifyingContract = r.schema.types['EIP712Domain']! + .any((f) => f.name == 'verifyingContract'); + return { + 'name': 'RealUnitUser', + 'version': '1', + if (hasChainId) 'chainId': r.chainId, + if (hasVerifyingContract) 'verifyingContract': r.verifyingContract, + }; + } + + Future _submitKyc(KycSignRequest r) async { + final message = { + 'accountType': r.accountType, + 'firstName': r.firstName, + 'lastName': r.lastName, + 'phone': r.phone, + 'addressStreet': r.addressStreet, + 'addressHouseNumber': r.addressHouseNumber, + 'addressZip': r.addressZip, + 'addressCity': r.addressCity, + 'addressCountry': r.addressCountry, + 'walletAddress': r.credentials.address.hexEip55, + 'registrationDate': r.registrationDate, + }; + final domain = { + 'name': 'RealUnitKyc', + 'version': '1', + 'chainId': r.chainId, + 'verifyingContract': r.verifyingContract, + }; + final typedDataMap = { + 'types': r.schema.typesAsJson(), + 'primaryType': r.schema.primaryType, + 'domain': domain, + 'message': message, + }; + final envelopeJson = jsonEncode(typedDataMap); + final dtoJson = jsonEncode(message); + final signature = await eip712Signer.signTypedDataEnvelope( + credentials: r.credentials, + chainId: r.chainId, + jsonEnvelope: envelopeJson, + ); + return TypedDataSignResult( + signature: signature, + envelopeJson: envelopeJson, + dtoJson: dtoJson, + ); + } + + Future _submitEip7702(Eip7702SignRequest r) async { + final data = r.eip7702Data; + final message = { + 'delegate': data.message.delegate, + 'delegator': data.message.delegator, + 'authority': data.message.authority, + 'caveats': data.message.caveats, + // uint256 salt as a decimal string — a BigInt is not JSON-encodable, and + // the typed-data encoder reads the string identically to a number. Used + // for BOTH the envelope and the dto below, so they stay byte-equal. + 'salt': data.message.salt.toString(), + }; + final domain = { + 'name': data.domain.name, + 'version': data.domain.version, + 'chainId': data.domain.chainId, + 'verifyingContract': data.domain.verifyingContract, + }; + final typedDataMap = { + 'types': r.schema.typesAsJson(), + 'primaryType': r.schema.primaryType, + 'domain': domain, + 'message': message, + }; + final envelopeJson = jsonEncode(typedDataMap); + final dtoJson = jsonEncode(message); + final signature = await eip712Signer.signTypedDataEnvelope( + credentials: r.credentials, + chainId: data.domain.chainId, + jsonEnvelope: envelopeJson, + ); + return TypedDataSignResult( + signature: signature, + envelopeJson: envelopeJson, + dtoJson: dtoJson, + ); + } +} diff --git a/test/packages/service/dfx/models/payment/eip7702_dtos_test.dart b/test/packages/service/dfx/models/payment/eip7702_dtos_test.dart index dd9b3408..73cea7f3 100644 --- a/test/packages/service/dfx/models/payment/eip7702_dtos_test.dart +++ b/test/packages/service/dfx/models/payment/eip7702_dtos_test.dart @@ -63,7 +63,25 @@ void main() { expect(dto.delegator, '0xdr'); expect(dto.authority, '0xauth'); expect(dto.caveats, hasLength(1)); - expect(dto.salt, 42); + expect(dto.salt, BigInt.from(42)); + }); + + // #608 F2: salt is a uint256 — a 256-bit value cannot fit in a Dart `int` + // (64-bit, 53-bit on web). Parsing it as `int` silently truncated the salt + // the user signs. Parse as BigInt from a number-or-string JSON value. + test('parses a full-width uint256 salt without truncation', () { + final huge = BigInt.parse('0x${'f' * 64}'); // 2^256 - 1, beyond int range + final dto = Eip7702Message.fromJson({ + 'delegate': '0xd', + 'delegator': '0xdr', + 'authority': '0xauth', + 'caveats': const [], + 'salt': huge.toString(), // backend sends large salts as a string + }); + expect(dto.salt, huge); + // The value is genuinely outside Dart's int range, so the old + // `json['salt'] as int` path could not have represented it. + expect(huge > BigInt.from(0x7fffffffffffffff), isTrue); }); }); @@ -101,7 +119,7 @@ void main() { expect(dto.relayerAddress, '0xrelay'); expect(dto.delegationManagerAddress, '0xmgr'); expect(dto.delegatorAddress, '0xdr'); - expect(dto.userNonce, 7); + expect(dto.userNonce, BigInt.from(7)); expect(dto.domain.chainId, 1); expect(dto.types.delegation, isEmpty); expect(dto.message.delegate, '0xd'); @@ -109,6 +127,19 @@ void main() { expect(dto.amountWei, '12345'); expect(dto.depositAddress, '0xdeposit'); }); + + // #608 F2: the EIP-7702 authorization nonce is a uint64; a value above + // 2^63 overflows a Dart int. Parse it as BigInt so the signed authorization + // tuple carries the exact nonce. + test('parses a uint64 userNonce beyond int range without overflow', () { + final bigNonce = BigInt.parse('18446744073709551615'); // 2^64 - 1 + final dto = Eip7702Data.fromJson({ + ...fullJson, + 'userNonce': bigNonce.toString(), + }); + expect(dto.userNonce, bigNonce); + expect(bigNonce > BigInt.from(0x7fffffffffffffff), isTrue); + }); }); group('$Eip7702AuthorizationDto.toJson', () { diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart index aacbe635..d877bfc1 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart @@ -42,9 +42,15 @@ final _walletAddress = _privKey.address.hexEip55.toLowerCase(); const _metaMaskDelegator = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b'; const _delegationManager = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; +// Now that confirmPayment signs through the schema-pinned envelope, the typed +// EIP-712 message is actually ABI-encoded — so address/bytes32 fields must be +// real hex (a 20-byte relayer and a 32-byte authority), not placeholders. +const _relayer = '0x0000000000000000000000000000000000000abc'; +const _authority = + '0x0000000000000000000000000000000000000000000000000000000000000000'; Map _validEip7702Json() => { - 'relayerAddress': '0xrelay', + 'relayerAddress': _relayer, 'delegationManagerAddress': _delegationManager, 'delegatorAddress': _metaMaskDelegator, 'userNonce': 7, @@ -54,14 +60,27 @@ Map _validEip7702Json() => { 'chainId': 1, 'verifyingContract': _delegationManager, }, + // Canonical MetaMask Delegation Framework v1.3.0 type set. confirmPayment + // now signs through the schema-pinned signDelegationEnvelope, which + // rejects any deviation from this set — so the fixture must carry the + // real types (matching test/integration/eip7702_delegation_bitbox_test). 'types': { - 'Delegation': >[], - 'Caveat': >[], + 'Delegation': >[ + {'name': 'delegate', 'type': 'address'}, + {'name': 'delegator', 'type': 'address'}, + {'name': 'authority', 'type': 'bytes32'}, + {'name': 'caveats', 'type': 'Caveat[]'}, + {'name': 'salt', 'type': 'uint256'}, + ], + 'Caveat': >[ + {'name': 'enforcer', 'type': 'address'}, + {'name': 'terms', 'type': 'bytes'}, + ], }, 'message': { - 'delegate': '0xrelay', + 'delegate': _relayer, 'delegator': _walletAddress, - 'authority': '0xauth', + 'authority': _authority, 'caveats': >[], 'salt': 0, }, @@ -143,9 +162,9 @@ void main() { final authorization = envelope['authorization'] as Map; // Delegation block mirrors the eip7702 data + carries a real signature. - expect(delegation['delegate'], '0xrelay'); + expect(delegation['delegate'], _relayer); expect(delegation['delegator'], _walletAddress); - expect(delegation['authority'], '0xauth'); + expect(delegation['authority'], _authority); expect(delegation['salt'], '0'); expect((delegation['signature'] as String).startsWith('0x'), isTrue); // 65-byte EIP-712 signature → 0x + 130 hex chars. diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart index f16db9f4..a18ca3c7 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_pa import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/styles/currency.dart'; @@ -242,4 +243,62 @@ void main() { ); }); }); + + // #608 F1 (HIGH): the defense-in-depth schema-pinning was dead code — the + // production confirmPayment used the legacy `Eip712Signer.signDelegation` + // static, which rebuilt the typed-data `types` VERBATIM from the backend + // payload. A backend that appended a hidden field to `Delegation`/`Caveat` + // would have it silently signed by the device. The fix routes signing + // through `signDelegationEnvelope`, which pins `types` against the client + // schema. `_validateEip7702Data` never inspected `types`, so these tests use + // an otherwise-valid payload — only the schema-pinning can catch the drift. + group('confirmPayment schema-pinning (F-038 / #608 F1)', () { + // The canonical MetaMask Delegation Framework v1.3.0 type set. + List> canonicalDelegation() => [ + {'name': 'delegate', 'type': 'address'}, + {'name': 'delegator', 'type': 'address'}, + {'name': 'authority', 'type': 'bytes32'}, + {'name': 'caveats', 'type': 'Caveat[]'}, + {'name': 'salt', 'type': 'uint256'}, + ]; + List> canonicalCaveat() => [ + {'name': 'enforcer', 'type': 'address'}, + {'name': 'terms', 'type': 'bytes'}, + ]; + + test('backend smuggles a hidden Delegation field → refused before signing', () async { + final json = _validEip7702Json(); + json['types'] = { + 'Delegation': [ + ...canonicalDelegation(), + // The smuggled field the user can never see in the amount UI. + {'name': 'secretApproval', 'type': 'uint256'}, + ], + 'Caveat': canonicalCaveat(), + }; + + await expectLater( + build().confirmPayment(_info(eip7702Override: json)), + // Schema drift is detected; without the migration the legacy verbatim + // rebuild would have signed it (failing later with an unrelated error). + throwsA(isA()), + ); + }); + + test('canonical types are accepted by the schema-pinning (no false positive)', () async { + final json = _validEip7702Json(); + json['types'] = { + 'Delegation': canonicalDelegation(), + 'Caveat': canonicalCaveat(), + }; + + // The pinning passes, so signing proceeds and fails only because the test + // fake credentials cannot actually sign — NOT a schema rejection. This + // pins that the migration does not reject legitimate payloads. + await expectLater( + build().confirmPayment(_info(eip7702Override: json)), + throwsA(isNot(isA())), + ); + }); + }); } diff --git a/test/packages/wallet/eip1559_type_byte_test.dart b/test/packages/wallet/eip1559_type_byte_test.dart new file mode 100644 index 00000000..689cce0a --- /dev/null +++ b/test/packages/wallet/eip1559_type_byte_test.dart @@ -0,0 +1,145 @@ +// Tier-0 tests for the EIP-1559 type-byte assert (F-040). +// +// What this pins (Initiative II / ADR 0002 step 9): +// +// * BitboxCredentials.signToSignature(payload, isEIP1559: true) +// refuses to strip the leading byte unless `payload[0] == 0x02`. +// A caller that mislabels a legacy transaction would otherwise sign +// a corrupted hash; the typed Eip1559TypeMismatchException is the +// contract the cubit / pipeline observes. +// * SignPipeline._validate enforces the same invariant at the +// pipeline boundary; mismatched type byte raises BEFORE the +// underlying credentials path even sees the payload. +// * Empty payload with isEIP1559=true is rejected by both layers +// (defence in depth). +// +// The assert sits in both BitboxCredentials (direct callers — legacy +// transfer path) AND SignPipeline._validate (modern pipeline callers). +// The dual-pin matches the ADR's "defence in depth" principle: each +// trust boundary refuses on its own evidence. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; +import 'package:realunit_wallet/packages/wallet/sign_pipeline.dart'; +import 'package:web3dart/web3dart.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + BitboxCredentials.resetSignQueue(); + + group('BitboxCredentials.signToSignature: EIP-1559 type-byte assert (F-040)', () { + test('payload[0] != 0x02 and isEIP1559=true → Eip1559TypeMismatchException', () async { + final credentials = BitboxCredentials( + '0x0000000000000000000000000000000000000001', + ); + final payload = Uint8List.fromList([0x01, 0xaa, 0xbb, 0xcc]); + expect( + () => credentials.signToSignature(payload, chainId: 1, isEIP1559: true), + throwsA( + isA() + .having((e) => e.actualByte, 'actualByte', 0x01), + ), + ); + }); + + test('payload empty and isEIP1559=true → Eip1559TypeMismatchException(actualByte=null)', () async { + final credentials = BitboxCredentials( + '0x0000000000000000000000000000000000000001', + ); + expect( + () => credentials.signToSignature( + Uint8List(0), + chainId: 1, + isEIP1559: true, + ), + throwsA( + isA() + .having((e) => e.actualByte, 'actualByte', null), + ), + ); + }); + + test('payload[0] == 0x02 and isEIP1559=true passes the assert', () async { + // Without a connected BitboxManager the sign throws + // BitboxNotConnectedException; what we are pinning here is that + // the type-byte assert does NOT fire — the failure comes from a + // different layer, proving the assert lets the well-formed + // payload through. + final credentials = BitboxCredentials( + '0x0000000000000000000000000000000000000001', + ); + final payload = Uint8List.fromList([0x02, 0xaa, 0xbb]); + expect( + () => credentials.signToSignature(payload, chainId: 1, isEIP1559: true), + throwsA(isNot(isA())), + ); + }); + + test('isEIP1559=false skips the assert entirely (legacy path)', () async { + final credentials = BitboxCredentials( + '0x0000000000000000000000000000000000000001', + ); + final payload = Uint8List.fromList([0x01, 0xaa]); + expect( + () => credentials.signToSignature(payload, chainId: 1, isEIP1559: false), + throwsA(isNot(isA())), + ); + }); + }); + + group('SignPipeline._validate: EIP-1559 type-byte assert (F-040)', () { + const pipeline = SignPipeline(); + final credentials = EthPrivateKey.fromHex( + 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612', + ); + + test('EthTransferSignRequest with payload[0]=0x01 and isEIP1559=true → ' + 'Eip1559TypeMismatchException at validate boundary', () async { + final request = EthTransferSignRequest( + credentials: credentials, + payload: Uint8List.fromList([0x01, 0xaa, 0xbb]), + chainId: 1, + isEIP1559: true, + ); + await expectLater( + pipeline.sign(request), + throwsA( + isA() + .having((e) => e.actualByte, 'actualByte', 0x01), + ), + ); + }); + + test('EthTransferSignRequest with empty payload → SignRequestValidationException', () async { + // Empty payload trips the non-empty-payload validator before the + // type-byte assert can run; the boundary refuses the request + // structurally rather than diving into the 0x02 check. + final request = EthTransferSignRequest( + credentials: credentials, + payload: Uint8List(0), + chainId: 1, + isEIP1559: true, + ); + await expectLater( + pipeline.sign(request), + throwsA(isA()), + ); + }); + + test('isEIP1559=false skips assert; legacy payload reaches the signer', () async { + // No BitBox so EthPrivateKey signs raw; pinning that the assert + // does NOT fire on legacy transfers. + final request = EthTransferSignRequest( + credentials: credentials, + payload: Uint8List.fromList([0x01, 0xaa, 0xbb]), + chainId: 1, + isEIP1559: false, + ); + final result = await pipeline.sign(request); + expect(result, isA()); + }); + }); +} diff --git a/test/packages/wallet/eip712_signer_chain_id_test.dart b/test/packages/wallet/eip712_signer_chain_id_test.dart new file mode 100644 index 00000000..6cd36b99 --- /dev/null +++ b/test/packages/wallet/eip712_signer_chain_id_test.dart @@ -0,0 +1,138 @@ +// Tier-0 property tests for the chainId-in-domain invariant (F-041). +// +// What this pins (Initiative II / ADR 0002 step 8): +// +// * Same registration payload signed on chainId=1 versus chainId=5 +// produces DIFFERENT signatures — cross-chain replay is now +// structurally impossible for V1-domain signs. +// * V0-legacy schema (no chainId in domain) still produces the SAME +// signature for both chainIds — this is the backend-rollout +// fallback. Tests pin the boundary: anyone migrating a callsite +// from V0 → V1 sees the cross-chain replay protection turn on. +// * The schema constant the signer uses determines the domain shape; +// a refactor that defaults to V0 silently loses F-041 protection +// and the boundary test fires. +// +// Property: +// +// For every (schemaV1, payload, chainId_a, chainId_b) with +// chainId_a != chainId_b → signature_a != signature_b. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/registration_schema.dart'; +import 'package:web3dart/web3dart.dart'; + +const _privateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; +const _verifyingContract = '0x000000000000000000000000000000000000beef'; + +Future _sign( + Eip712Signer signer, + int chainId, { + required dynamic schema, +}) { + return signer.signRegistrationEnvelope( + credentials: EthPrivateKey.fromHex(_privateKeyHex), + chainId: chainId, + email: 'cross-chain@dfx.swiss', + name: 'Cross Chain User', + type: 'human', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-05-23', + verifyingContract: _verifyingContract, + schema: schema, + ); +} + +void main() { + const signer = Eip712Signer(); + + group('F-041: chainId in registration domain (V1)', () { + test( + 'same payload on chainId=1 vs chainId=5 produces DIFFERENT signatures', + () async { + const schema = RegistrationSchemaV1(); + final sigEth = await _sign(signer, 1, schema: schema); + final sigGoerli = await _sign(signer, 5, schema: schema); + expect(sigEth, isNotEmpty); + expect(sigGoerli, isNotEmpty); + expect( + sigEth, + isNot(equals(sigGoerli)), + reason: + 'V1 domain includes chainId — a sig on chain A must not ' + 'replay on chain B; F-041 would otherwise leave registration ' + 'signatures cross-chain replayable.', + ); + }, + ); + + test('property: every pair of distinct chainIds yields distinct signatures', () async { + const schema = RegistrationSchemaV1(); + const chains = [1, 5, 10, 56, 137, 8453, 42161]; + final signatures = {}; + for (final c in chains) { + signatures[c] = await _sign(signer, c, schema: schema); + } + // For every unordered pair (a, b) with a < b, signatures must + // differ. This is the cross-chain replay safety invariant pinned + // explicitly across a meaningful spread of mainnets / L2s / testnets. + for (var i = 0; i < chains.length; i++) { + for (var j = i + 1; j < chains.length; j++) { + expect( + signatures[chains[i]], + isNot(equals(signatures[chains[j]])), + reason: + 'chainId ${chains[i]} signature collides with ${chains[j]}; ' + 'F-041 cross-chain replay protection broken.', + ); + } + } + }); + + test('idempotence: same payload on same chainId is byte-stable', () async { + const schema = RegistrationSchemaV1(); + final sigA = await _sign(signer, 1, schema: schema); + final sigB = await _sign(signer, 1, schema: schema); + expect( + sigA, + sigB, + reason: + 'eth_sig_util V4 is deterministic; a refactor that introduces ' + 'non-determinism (e.g. a random salt) would break replay-safety ' + 'guarantees and break this pin.', + ); + }); + }); + + group('V0-legacy boundary (no chainId in domain)', () { + test( + 'V0-legacy schema: same payload on chainId=1 vs chainId=5 still produces SAME signature', + () async { + // The V0 domain is `name + version` only — the chainId is not + // part of the signed hash. This is the legacy behaviour the + // production backend currently still expects (backend rollout + // window pinned in ADR 0002 §Failure modes). New callers go + // through the SignPipeline with V1; the boundary test exists so + // a refactor that silently defaults to V0 cannot escape audit. + const schema = RegistrationSchemaV0(); + final sigEth = await _sign(signer, 1, schema: schema); + final sigGoerli = await _sign(signer, 5, schema: schema); + expect( + sigEth, + equals(sigGoerli), + reason: + 'V0-legacy domain does not include chainId — cross-chain ' + 'replay is structurally possible; the V1 schema closes this.', + ); + }, + ); + }); +} diff --git a/test/packages/wallet/eip712_signer_delegation_test.dart b/test/packages/wallet/eip712_signer_delegation_test.dart new file mode 100644 index 00000000..7ef8e39b --- /dev/null +++ b/test/packages/wallet/eip712_signer_delegation_test.dart @@ -0,0 +1,401 @@ +// Tier-0 tests for Eip712Signer.signDelegationEnvelope — EIP-7702 +// schema pinning with explicit expected parameters. +// +// What this pins (Initiative II / ADR 0002 step 7): +// +// * F-038 — backend-supplied `types.delegation` adding a hidden field +// raises Eip712SchemaDriftException BEFORE any byte reaches the +// underlying eth_sig_util signer. +// * F-039 — verifyingContract / chainId / delegator / amount that +// differ from the expected pinned values raise +// Eip7702ExpectedParamsMismatchException with the parameter name +// populated, so the cubit can log which field drifted. +// * Happy path — a backend response that matches all four pinned +// parameters AND the pinned schema produces a non-empty signature. +// +// The signer validates internally, refusing to delegate the validation +// to "the caller will check it" — encapsulation lives inside the trust +// boundary. Closes the failure-mode entry in ADR 0002 §Failure modes: +// "Schema constant drift from backend ↘ caught by _pinSchema byte-equal". + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; +import 'package:web3dart/web3dart.dart'; + +const _privateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; +// Derived from _privateKeyHex via EthPrivateKey.fromHex(...).address.hexEip55. +const _testAddress = '0xD29C323DfD441E5157F5a05ccE6c74aC94c57aAd'; +const _verifyingContract = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; +const _relayer = '0x0000000000000000000000000000000000000abc'; + +Eip7702Data _validResponse({ + int chainId = 1, + String verifyingContract = _verifyingContract, + String delegator = _testAddress, + String amountWei = '1000000000000000000', // 1 ETH + List? delegation, + List? caveat, +}) { + return Eip7702Data( + relayerAddress: _relayer, + delegationManagerAddress: _verifyingContract, + delegatorAddress: delegator, + userNonce: BigInt.zero, + domain: Eip7702Domain( + name: 'DelegationManager', + version: '1', + chainId: chainId, + verifyingContract: verifyingContract, + ), + types: Eip7702Types( + delegation: + delegation ?? + const [ + Eip7702TypeField(name: 'delegate', type: 'address'), + Eip7702TypeField(name: 'delegator', type: 'address'), + Eip7702TypeField(name: 'authority', type: 'bytes32'), + Eip7702TypeField(name: 'caveats', type: 'Caveat[]'), + Eip7702TypeField(name: 'salt', type: 'uint256'), + ], + caveat: + caveat ?? + const [ + Eip7702TypeField(name: 'enforcer', type: 'address'), + Eip7702TypeField(name: 'terms', type: 'bytes'), + ], + ), + message: Eip7702Message( + delegate: _relayer, + delegator: delegator, + authority: + '0x0000000000000000000000000000000000000000000000000000000000000000', + caveats: const [], + salt: BigInt.zero, + ), + tokenAddress: '0x0000000000000000000000000000000000000aaa', + amountWei: amountWei, + depositAddress: '0x0000000000000000000000000000000000000bbb', + ); +} + +void main() { + final credentials = EthPrivateKey.fromHex(_privateKeyHex); + const signer = Eip712Signer(); + + group('Eip712Signer.signDelegationEnvelope expected-params pinning (F-039)', () { + test('happy path: pinned params match → returns non-empty signature', () async { + final sig = await signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ); + expect(sig, isNotEmpty); + expect(sig, startsWith('0x')); + }); + + // #608 F4: the EIP-712 domain `name`/`version` feed the domain separator + // the user signs and were previously unvalidated. When the caller pins + // them, a backend that swaps either is refused before signing. + test('domain.name drift → Eip7702ExpectedParamsMismatchException(domain.name)', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(), // domain.name == 'DelegationManager' + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + expectedDomainName: 'SomethingElse', + ), + throwsA( + isA().having( + (e) => e.parameter, + 'parameter', + 'domain.name', + ), + ), + ); + }); + + test('domain.version drift → Eip7702ExpectedParamsMismatchException(domain.version)', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(), // domain.version == '1' + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + expectedDomainVersion: '2', + ), + throwsA( + isA().having( + (e) => e.parameter, + 'parameter', + 'domain.version', + ), + ), + ); + }); + + test('matching pinned domain name + version → signs (no false positive)', () async { + final sig = await signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + expectedDomainName: 'DelegationManager', + expectedDomainVersion: '1', + ); + expect(sig, isNotEmpty); + }); + + test('verifyingContract drift → Eip7702ExpectedParamsMismatchException', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(), + expectedVerifyingContract: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA( + isA().having( + (e) => e.parameter, + 'parameter', + 'verifyingContract', + ), + ), + ); + }); + + test('chainId drift → Eip7702ExpectedParamsMismatchException carrying chainId', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(chainId: 1), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 5, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA( + isA() + .having((e) => e.parameter, 'parameter', 'chainId') + .having((e) => e.expected, 'expected', '5') + .having((e) => e.actual, 'actual', '1'), + ), + ); + }); + + test('delegator drift → Eip7702ExpectedParamsMismatchException', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse( + delegator: '0x0000000000000000000000000000000000001234', + ), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA( + isA().having( + (e) => e.parameter, + 'parameter', + 'delegator', + ), + ), + ); + }); + + test('amount drift → Eip7702ExpectedParamsMismatchException', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(amountWei: '500000000000000000'), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA( + isA().having( + (e) => e.parameter, + 'parameter', + 'amountWei', + ), + ), + ); + }); + + test('case-insensitive verifyingContract compare (mixed case via EIP-55)', () async { + // The pinned compare lowercases both sides — the signer does not + // care whether the backend ships EIP-55 mixed-case or lowercase. + // Use a valid EIP-55 spelling here so the downstream eth_sig_util + // address encoder can still parse the bytes. + const mixedCase = '0xdB9b1E94B5B69dF7e401dDBEdE43491141047Db3'; + final sig = await signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(verifyingContract: mixedCase), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ); + expect(sig, isNotEmpty); + }); + + test('case-insensitive delegator compare (mixed case via EIP-55)', () async { + // _testAddress is already mixed-case (EIP-55); compare against the + // lowercased spelling — the signer must accept either side. + final sig = await signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse(delegator: _testAddress), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress.toLowerCase(), + expectedAmount: BigInt.from(10).pow(18), + ); + expect(sig, isNotEmpty); + }); + }); + + group('Eip712Signer.signDelegationEnvelope schema pinning (F-038)', () { + test('backend adds a hidden field → Eip712SchemaDriftException', () async { + // The attack scenario the ADR explicitly names: a malicious / + // MITM-ed backend smuggles `{name: "secretApproval", type: + // "uint256"}` into the Delegation field list. The signer MUST + // refuse before any byte reaches the device. + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse( + delegation: const [ + Eip7702TypeField(name: 'delegate', type: 'address'), + Eip7702TypeField(name: 'delegator', type: 'address'), + Eip7702TypeField(name: 'authority', type: 'bytes32'), + Eip7702TypeField(name: 'caveats', type: 'Caveat[]'), + Eip7702TypeField(name: 'salt', type: 'uint256'), + Eip7702TypeField(name: 'secretApproval', type: 'uint256'), + ], + ), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA( + isA().having( + (e) => e.schemaVersion, + 'schemaVersion', + 'eip7702-delegation/v1', + ), + ), + ); + }); + + test('backend drops `salt` → Eip712SchemaDriftException', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse( + delegation: const [ + Eip7702TypeField(name: 'delegate', type: 'address'), + Eip7702TypeField(name: 'delegator', type: 'address'), + Eip7702TypeField(name: 'authority', type: 'bytes32'), + Eip7702TypeField(name: 'caveats', type: 'Caveat[]'), + ], + ), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA(isA()), + ); + }); + + test('backend swaps delegate ↔ delegator → Eip712SchemaDriftException', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse( + delegation: const [ + Eip7702TypeField(name: 'delegator', type: 'address'), + Eip7702TypeField(name: 'delegate', type: 'address'), + Eip7702TypeField(name: 'authority', type: 'bytes32'), + Eip7702TypeField(name: 'caveats', type: 'Caveat[]'), + Eip7702TypeField(name: 'salt', type: 'uint256'), + ], + ), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA(isA()), + ); + }); + + test('backend changes Caveat.terms from bytes to bytes32 → Eip712SchemaDriftException', () async { + await expectLater( + signer.signDelegationEnvelope( + credentials: credentials, + eip7702Data: _validResponse( + caveat: const [ + Eip7702TypeField(name: 'enforcer', type: 'address'), + Eip7702TypeField(name: 'terms', type: 'bytes32'), + ], + ), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ), + throwsA(isA()), + ); + }); + }); + + group('Eip712Signer.signKycEnvelope (NEW-19 future path)', () { + test('happy path: produces a non-empty signature', () async { + final sig = await signer.signKycEnvelope( + credentials: credentials, + chainId: 1, + verifyingContract: _verifyingContract, + accountType: 'PERSONAL', + firstName: 'Test', + lastName: 'User', + phone: '+41790000000', + addressStreet: 'Teststrasse', + addressHouseNumber: '1', + addressZip: '8000', + addressCity: 'Zurich', + addressCountry: 41, + registrationDate: '2026-05-23', + ); + expect(sig, startsWith('0x')); + expect(sig.length, 132); + }); + }); + + group('Eip712Signer.signDelegation static legacy wrapper', () { + test('delegates to the instance signer; produces a non-empty signature', () async { + final sig = await Eip712Signer.signDelegation( + credentials: credentials, + eip7702Data: _validResponse(), + ); + expect(sig, startsWith('0x')); + }); + }); +} diff --git a/test/packages/wallet/eip712_signer_test.dart b/test/packages/wallet/eip712_signer_test.dart index 0ac8d37c..38c419d4 100644 --- a/test/packages/wallet/eip712_signer_test.dart +++ b/test/packages/wallet/eip712_signer_test.dart @@ -167,24 +167,24 @@ void main() { // caveats list are valid inputs the backend already returns for a // bare delegation. Anything heavier would only re-test the JSON // serialiser of eip7702_data_dto. - const delegationData = Eip7702Data( + final delegationData = Eip7702Data( relayerAddress: '0x0000000000000000000000000000000000000010', delegationManagerAddress: '0x0000000000000000000000000000000000000011', delegatorAddress: '0x0000000000000000000000000000000000000012', - userNonce: 0, - domain: Eip7702Domain( + userNonce: BigInt.zero, + domain: const Eip7702Domain( name: 'RealUnitDelegation', version: '1', chainId: 1, verifyingContract: '0x0000000000000000000000000000000000000013', ), - types: Eip7702Types(delegation: [], caveat: []), + types: const Eip7702Types(delegation: [], caveat: []), message: Eip7702Message( delegate: '0x0000000000000000000000000000000000000014', delegator: '0x0000000000000000000000000000000000000015', authority: '0x0000000000000000000000000000000000000016', caveats: [], - salt: 0, + salt: BigInt.zero, ), tokenAddress: '0x0000000000000000000000000000000000000017', amountWei: '0', diff --git a/test/packages/wallet/eip7702_signer_test.dart b/test/packages/wallet/eip7702_signer_test.dart index 058ab540..261a2f3b 100644 --- a/test/packages/wallet/eip7702_signer_test.dart +++ b/test/packages/wallet/eip7702_signer_test.dart @@ -13,7 +13,7 @@ Eip7702Data _data({int chainId = 1, int userNonce = 0}) => Eip7702Data( relayerAddress: '0x0000000000000000000000000000000000000002', delegationManagerAddress: '0x0000000000000000000000000000000000000003', delegatorAddress: _delegatorAddress, - userNonce: userNonce, + userNonce: BigInt.from(userNonce), domain: Eip7702Domain( name: 'RealUnit', version: '1', @@ -21,12 +21,12 @@ Eip7702Data _data({int chainId = 1, int userNonce = 0}) => Eip7702Data( verifyingContract: '0x0000000000000000000000000000000000000004', ), types: const Eip7702Types(delegation: [], caveat: []), - message: const Eip7702Message( + message: Eip7702Message( delegate: '0x0000000000000000000000000000000000000005', delegator: _delegatorAddress, authority: '0x0000000000000000000000000000000000000006', caveats: [], - salt: 0, + salt: BigInt.zero, ), tokenAddress: '0x0000000000000000000000000000000000000007', amountWei: '0', diff --git a/test/packages/wallet/error_mapper_test.dart b/test/packages/wallet/error_mapper_test.dart new file mode 100644 index 00000000..84685154 --- /dev/null +++ b/test/packages/wallet/error_mapper_test.dart @@ -0,0 +1,447 @@ +// Exhaustive Tier-0 contract test for [ErrorMapper] + every typed +// [SignException] subclass. Fails the build if: +// +// * a known BitBox error code (`ErrorMapper.knownCodes`) is missing a +// typed exception — instead of staying silent and surfacing as +// `BitboxUnknownException`, the mapper MUST narrow the cause. +// * a typed exception's ARB key is empty or duplicated. +// * a typed exception's ARB key is missing from +// `assets/languages/strings_de.arb` or `_en.arb` (the user would see +// an empty string at runtime — symptomatic of the F-016 / F-020 +// regression class). +// * a previously-untyped cause path (legacy [SigningCancelledException], +// legacy [BitboxNotConnectedException]) is not converted to its +// typed sibling. +// * an unknown native code (999) crashes instead of becoming +// `BitboxUnknownException(rawCode)`. +// +// Why exhaustive: the audit's F-016 / F-020 / F-021 cluster is a class +// of bugs where cubits did `catch (e) { e.toString() }` matching. Every +// rename of an underlying type silently broke the special-handling +// branch. The mapper plus this test makes "add a new error, forget to +// add the typed exception" impossible — the build turns red before the +// release. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; + +Map _readArb(String path) { + final raw = File(path).readAsStringSync(); + return jsonDecode(raw) as Map; +} + +void main() { + group('ErrorMapper.mapBitboxCode', () { + const mapper = ErrorMapper(); + + test('constructor can be instantiated at runtime', () { + final mapperFactory = ErrorMapper.new; + expect(mapperFactory(), isA()); + }); + + test('101 ErrInvalidInput → BitboxInvalidInputException with detail', () { + final result = mapper.mapBitboxCode(101, message: 'non-ASCII char'); + expect(result, isA()); + expect((result as BitboxInvalidInputException).detail, 'non-ASCII char'); + expect(result.arbKey, 'errorBitboxInvalidInput'); + }); + + test('102 ErrUserAbort → BitboxUserAbortException', () { + final result = mapper.mapBitboxCode(102); + expect(result, isA()); + expect(result.arbKey, 'errorBitboxUserAbort'); + }); + + test('103 channel-hash → BitboxChannelHashMismatchException', () { + final result = mapper.mapBitboxCode(103); + expect(result, isA()); + expect(result.arbKey, 'errorBitboxChannelHashMismatch'); + }); + + test('104 transport timeout → BitboxTimeoutException', () { + final result = mapper.mapBitboxCode(104); + expect(result, isA()); + expect(result.arbKey, 'errorBitboxTimeout'); + }); + + test('unknown code 999 → BitboxUnknownException(999); never crashes', () { + final result = mapper.mapBitboxCode(999, message: 'oops'); + expect(result, isA()); + final unknown = result as BitboxUnknownException; + expect(unknown.rawCode, 999); + expect(unknown.message, 'oops'); + expect(result.arbKey, 'errorBitboxUnknown'); + }); + + test('every known code in ErrorMapper.knownCodes maps to a non-unknown typed exception', () { + // If a code is in `knownCodes` it MUST narrow to a specific typed + // exception. A code that surfaces as `BitboxUnknownException` while + // also being in `knownCodes` is a symptom of a missing case branch. + for (final code in ErrorMapper.knownCodes) { + final result = mapper.mapBitboxCode(code); + expect( + result, + isNot(isA()), + reason: + 'code $code is in knownCodes but maps to BitboxUnknownException — ' + 'add a typed exception + case branch in ErrorMapper.mapBitboxCode', + ); + } + }); + + test( + 'codes outside knownCodes (negative, zero, very large) all surface as BitboxUnknownException', + () { + for (final code in [-1, 0, 1, 500, 9999, 0x7FFFFFFF]) { + if (ErrorMapper.knownCodes.contains(code)) continue; + final result = mapper.mapBitboxCode(code); + expect(result, isA(), reason: 'code $code'); + expect((result as BitboxUnknownException).rawCode, code); + } + }, + ); + }); + + group('ErrorMapper.mapCause', () { + const mapper = ErrorMapper(); + + test('a SignException is returned as-is (identity)', () { + const original = BitboxUserAbortException(); + expect(identical(mapper.mapCause(original), original), isTrue); + }); + + test('legacy SigningCancelledException → typed SigningCancelledSignException', () { + const cause = SigningCancelledException(); + final result = mapper.mapCause(cause); + expect(result, isA()); + expect(result.arbKey, 'errorSigningCancelled'); + }); + + test('legacy BitboxNotConnectedException → typed BitboxNotConnectedSignException', () { + const cause = BitboxNotConnectedException(); + final result = mapper.mapCause(cause); + expect(result, isA()); + expect(result.arbKey, 'errorBitboxNotConnected'); + }); + + test('arbitrary Object → BitboxUnknownException(rawCode=-1); never crashes', () { + final result = mapper.mapCause('a random string error'); + expect(result, isA()); + expect((result as BitboxUnknownException).rawCode, -1); + expect(result.message, contains('a random string error')); + }); + + test('Exception subclass → BitboxUnknownException(-1) with toString message', () { + final cause = Exception('socket closed'); + final result = mapper.mapCause(cause); + expect(result, isA()); + expect((result as BitboxUnknownException).message, contains('socket closed')); + }); + }); + + group('SignException ARB key contract', () { + final exceptions = allKnownSignExceptions(); + + test('allKnownSignExceptions covers every concrete subclass at least once', () { + // Hand-maintained registry mirror — if a new concrete subclass is + // added to `error_mapper.dart` without an entry here, the test + // fails. The names are the canonical list of typed exceptions the + // pipeline can emit; cubits switch on these types. + final classNames = exceptions.map((e) => e.runtimeType.toString()).toSet(); + expect( + classNames, + containsAll({ + 'BitboxInvalidInputException', + 'BitboxUserAbortException', + 'BitboxChannelHashMismatchException', + 'BitboxTimeoutException', + 'BitboxNotConnectedSignException', + 'BitboxUnknownException', + 'Eip712SchemaDriftException', + 'Eip7702NotSupportedException', + 'Eip1559TypeMismatchException', + 'Eip7702ExpectedParamsMismatchException', + 'SignRequestValidationException', + 'SigningCancelledSignException', + 'BtcPsbtInvalidException', + }), + ); + }); + + test('every typed SignException has a non-empty ARB key', () { + for (final ex in exceptions) { + expect(ex.arbKey, isNotEmpty, reason: '${ex.runtimeType} missing ARB key'); + expect( + ex.arbKey.trim(), + ex.arbKey, + reason: '${ex.runtimeType} ARB key has whitespace', + ); + } + }); + + test('ARB keys are unique across the hierarchy (no copy-paste collision)', () { + final keys = exceptions.map((e) => e.arbKey).toList(); + final unique = keys.toSet(); + expect( + keys.length, + unique.length, + reason: 'Duplicate ARB key — every typed exception needs its own message: $keys', + ); + }); + + test('every ARB key is present in BOTH strings_de.arb AND strings_en.arb', () { + // Surface the user-visible-string contract right here so a + // refactor that adds a typed exception but forgets the i18n + // entries fails the build. A missing key at runtime would leave + // the user with an empty SnackBar — symptomatic of the F-016 + // regression class the typed hierarchy exists to prevent. + final de = _readArb('assets/languages/strings_de.arb'); + final en = _readArb('assets/languages/strings_en.arb'); + for (final ex in exceptions) { + expect( + de.keys, + contains(ex.arbKey), + reason: '${ex.runtimeType}: ARB key "${ex.arbKey}" missing in strings_de.arb', + ); + expect( + en.keys, + contains(ex.arbKey), + reason: '${ex.runtimeType}: ARB key "${ex.arbKey}" missing in strings_en.arb', + ); + expect((de[ex.arbKey] as String).trim(), isNotEmpty); + expect((en[ex.arbKey] as String).trim(), isNotEmpty); + } + }); + }); + + group('Typed exception equality + diagnostics', () { + test('value equality on parametric exceptions', () { + expect( + const BitboxInvalidInputException(detail: 'x'), + const BitboxInvalidInputException(detail: 'x'), + ); + expect( + const BitboxInvalidInputException(detail: 'x'), + isNot(const BitboxInvalidInputException(detail: 'y')), + ); + expect( + const BitboxUnknownException(999, message: 'm'), + const BitboxUnknownException(999, message: 'm'), + ); + expect( + const BitboxUnknownException(999, message: 'm'), + isNot(const BitboxUnknownException(999, message: 'other')), + ); + expect( + const Eip1559TypeMismatchException(actualByte: 0x01), + const Eip1559TypeMismatchException(actualByte: 0x01), + ); + expect( + const Eip1559TypeMismatchException(actualByte: 0x01), + isNot(const Eip1559TypeMismatchException(actualByte: 0x02)), + ); + expect( + const Eip7702ExpectedParamsMismatchException( + parameter: 'chainId', + expected: '1', + actual: '5', + ), + const Eip7702ExpectedParamsMismatchException( + parameter: 'chainId', + expected: '1', + actual: '5', + ), + ); + expect( + const SignRequestValidationException(field: 'email', reason: 'empty'), + const SignRequestValidationException(field: 'email', reason: 'empty'), + ); + }); + + test('parametric equality compares each EIP-7702 mismatch field', () { + final chainId = ['chainId'].single; + final delegator = ['delegator'].single; + final mainnet = ['1'].single; + final polygon = ['137'].single; + final optimism = ['5'].single; + final a = Eip7702ExpectedParamsMismatchException( + parameter: chainId, + expected: mainnet, + actual: optimism, + ); + final b = Eip7702ExpectedParamsMismatchException( + parameter: chainId, + expected: mainnet, + actual: optimism, + ); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect( + a, + isNot( + Eip7702ExpectedParamsMismatchException( + parameter: delegator, + expected: mainnet, + actual: optimism, + ), + ), + ); + expect( + a, + isNot( + Eip7702ExpectedParamsMismatchException( + parameter: chainId, + expected: polygon, + actual: optimism, + ), + ), + ); + expect( + a, + isNot( + Eip7702ExpectedParamsMismatchException( + parameter: chainId, + expected: mainnet, + actual: polygon, + ), + ), + ); + }); + + test('parametric equality compares each validation field', () { + final email = ['email'].single; + final name = ['name'].single; + final empty = ['empty'].single; + final blank = ['blank'].single; + final a = SignRequestValidationException(field: email, reason: empty); + final b = SignRequestValidationException(field: email, reason: empty); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(SignRequestValidationException(field: name, reason: empty))); + expect(a, isNot(SignRequestValidationException(field: email, reason: blank))); + }); + + test('toString includes the raw code for unknown exceptions (telemetry)', () { + const ex = BitboxUnknownException(987, message: 'firmware says no'); + expect(ex.toString(), contains('987')); + expect(ex.toString(), contains('firmware says no')); + }); + + test('toString includes the actual byte for EIP-1559 mismatch (developer hint)', () { + const ex = Eip1559TypeMismatchException(actualByte: 0x01); + expect(ex.toString(), contains('0x1')); + expect(ex.toString(), contains('0x02')); + }); + + test('toString of Eip712SchemaDriftException carries field/version/reason', () { + const ex = Eip712SchemaDriftException( + driftedField: 'Delegation[3].type', + schemaVersion: 'eip7702-delegation/v1', + reason: 'extra field secretApproval', + ); + expect(ex.toString(), contains('Delegation[3].type')); + expect(ex.toString(), contains('eip7702-delegation/v1')); + expect(ex.toString(), contains('extra field secretApproval')); + }); + + test('toString + hashCode exercise every typed exception (coverage pin)', () { + // Each typed exception's toString / hashCode / operator == / + // arbKey is part of the SignException contract. Exercising them + // here ensures the coverage gate stays green when a refactor + // forgets to wire one of the boilerplate overrides. + for (final ex in allKnownSignExceptions()) { + expect(ex.toString(), isNotEmpty); + expect(ex.hashCode, isA()); + // identical() short-circuit + // ignore: unrelated_type_equality_checks + expect(ex == ex, isTrue); + // not-equal to a non-SignException + expect(ex == Object(), isFalse); + expect(ex.arbKey, isNotEmpty); + } + }); + + test('value equality on every reference-equality exception', () { + // Singleton-style typed exceptions have value equality even + // though they carry no fields. + expect( + const BitboxUserAbortException(), + const BitboxUserAbortException(), + ); + expect( + const BitboxChannelHashMismatchException(), + const BitboxChannelHashMismatchException(), + ); + expect( + const BitboxTimeoutException(), + const BitboxTimeoutException(), + ); + expect( + const BitboxNotConnectedSignException(), + const BitboxNotConnectedSignException(), + ); + expect( + const Eip7702NotSupportedException(), + const Eip7702NotSupportedException(), + ); + expect( + const SigningCancelledSignException(), + const SigningCancelledSignException(), + ); + }); + + test('Eip712SchemaDriftException value equality', () { + final fieldX = ['X'].single; + final fieldY = ['Y'].single; + final version1 = ['v1'].single; + final version2 = ['v2'].single; + final reasonR = ['r'].single; + final reasonS = ['s'].single; + final a = Eip712SchemaDriftException( + driftedField: fieldX, + schemaVersion: version1, + reason: reasonR, + ); + final b = Eip712SchemaDriftException( + driftedField: fieldX, + schemaVersion: version1, + reason: reasonR, + ); + expect(a, b); + expect(a.hashCode, b.hashCode); + final c = Eip712SchemaDriftException( + driftedField: fieldY, + schemaVersion: version1, + reason: reasonR, + ); + expect(a, isNot(c)); + final d = Eip712SchemaDriftException( + driftedField: fieldX, + schemaVersion: version2, + reason: reasonR, + ); + expect(a, isNot(d)); + final e = Eip712SchemaDriftException( + driftedField: fieldX, + schemaVersion: version1, + reason: reasonS, + ); + expect(a, isNot(e)); + }); + + test('BtcPsbtInvalidException value equality + toString', () { + const a = BtcPsbtInvalidException('empty'); + const b = BtcPsbtInvalidException('empty'); + const c = BtcPsbtInvalidException('wrong magic'); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + expect(a.toString(), contains('empty')); + }); + }); +} diff --git a/test/packages/wallet/schemas/btc_psbt_schema_test.dart b/test/packages/wallet/schemas/btc_psbt_schema_test.dart new file mode 100644 index 00000000..bbf9689c --- /dev/null +++ b/test/packages/wallet/schemas/btc_psbt_schema_test.dart @@ -0,0 +1,92 @@ +// Tier-0 tests for the BTC PSBT pseudo-schema. +// +// PSBT is not typed-data, so the schema's job is two-fold: +// 1. expose the same `Eip712Schema` API surface as the other schemas (so +// the pipeline can iterate over a uniform schema set) +// 2. pre-flight the raw PSBT bytes — empty / too-short / wrong-magic — +// with a typed exception before they reach the BitBox plugin. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/btc_psbt_schema.dart'; + +const _schema = BtcPsbtSchema(); + +void main() { + group('BtcPsbtSchema', () { + test('schemaVersion + primaryType are pinned', () { + // Version is the migration hook for PSBT-v2 / Schnorr rollout. The + // testkit's `BtcPsbtMultiInputSign` scenario references this exact + // string so the coverage-honesty CI knows which version it covers. + final schemaFactory = BtcPsbtSchema.new; + final runtimeSchema = schemaFactory(); + expect(runtimeSchema.types, isEmpty); + expect(_schema.schemaVersion, 'btc-psbt/v1'); + expect(_schema.primaryType, 'BTC_PSBT'); + }); + + test('validate(Map) explicitly errors out', () { + // PSBT has no typed-data envelope — calling validate(Map) is a + // programming error. We surface it as a StateError instead of + // silently passing, so a future caller that mis-routes a PSBT + // through the EIP-712 path gets a loud failure. + expect( + () => _schema.validate(const {}), + throwsA(isA()), + ); + }); + + test('validatePsbt accepts a well-formed PSBT prefix', () { + // BIP-174 magic bytes: psbt\xff. Anything that starts with this + // five-byte prefix is structurally valid at this layer; the actual + // BIP-174 parse happens inside the BitBox firmware. + final ok = Uint8List.fromList([0x70, 0x73, 0x62, 0x74, 0xff, 0x00, 0x00]); + expect(() => _schema.validatePsbt(ok), returnsNormally); + }); + + test('validatePsbt rejects empty payloads', () { + // Don't send zero bytes through the BLE/USB pipe — the device would + // either time out or return an unhelpful generic error. + expect( + () => _schema.validatePsbt(Uint8List(0)), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('empty'), + ), + ), + ); + }); + + test('validatePsbt rejects payloads shorter than the magic-bytes prefix', () { + // A 4-byte payload is impossible per BIP-174 — fail fast. + expect( + () => _schema.validatePsbt(Uint8List.fromList([0x70, 0x73, 0x62, 0x74])), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('shorter than magic'), + ), + ), + ); + }); + + test('validatePsbt rejects a payload with a wrong magic byte', () { + // The fifth byte must be 0xff per BIP-174. A 0x00 here is a clear + // protocol mismatch — surface the exact offset for triage. + expect( + () => _schema.validatePsbt(Uint8List.fromList([0x70, 0x73, 0x62, 0x74, 0x00, 0x00, 0x00])), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + allOf(contains('offset 4'), contains('0x0'), contains('0xff')), + ), + ), + ); + }); + }); +} diff --git a/test/packages/wallet/schemas/eip712_schema_test.dart b/test/packages/wallet/schemas/eip712_schema_test.dart new file mode 100644 index 00000000..8911eae5 --- /dev/null +++ b/test/packages/wallet/schemas/eip712_schema_test.dart @@ -0,0 +1,327 @@ +// Tier-0 base-class tests for Eip712Schema + the byte-equal compare +// invariant against backend-supplied types maps. +// +// These tests pin the contract: +// - extra type group → drift +// - missing type group → drift +// - extra field in a group → drift +// - missing field in a group → drift +// - reordered fields → drift (EIP-712 hashes are order-sensitive) +// - renamed field → drift +// - wrong type on a field → drift +// - extra key beyond {name,type} → drift (e.g. `internalType` smuggled in) +// - non-string name/type → drift +// - non-list type group → drift +// - identical maps → accept (no throw) +// +// The schema below is a deliberately minimal test fixture so the asserts +// stay focused on the comparator. Real-world schemas (registration, +// EIP-7702, KYC) inherit the same comparator via `Eip712Schema`. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip712_schema.dart'; + +class _TwoFieldSchema extends Eip712Schema { + const _TwoFieldSchema(); + + @override + String get schemaVersion => 'test/v1'; + + @override + String get primaryType => 'Foo'; + + @override + Map> get types => const { + 'EIP712Domain': [ + Eip712FieldSpec('name', 'string'), + Eip712FieldSpec('version', 'string'), + ], + 'Foo': [ + Eip712FieldSpec('alpha', 'string'), + Eip712FieldSpec('beta', 'uint256'), + ], + }; +} + +const _schema = _TwoFieldSchema(); + +Map _matching() => { + 'EIP712Domain': [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + ], + 'Foo': [ + {'name': 'alpha', 'type': 'string'}, + {'name': 'beta', 'type': 'uint256'}, + ], +}; + +void main() { + group('Eip712FieldSpec', () { + test('value equality, hashCode and diagnostics include name and type', () { + final amount = ['amount'].single; + final recipient = ['recipient'].single; + final uint256 = ['uint256'].single; + final address = ['address'].single; + final a = Eip712FieldSpec(amount, uint256); + final b = Eip712FieldSpec(amount, uint256); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(Eip712FieldSpec(recipient, uint256))); + expect(a, isNot(Eip712FieldSpec(amount, address))); + expect(a.toString(), '{amount: uint256}'); + }); + }); + + group('Eip712Schema.validate', () { + test('accepts a byte-equal map (control case)', () { + // The baseline: backend response equals the pinned schema. validate() + // must not throw — otherwise every legitimate sign would drift-reject. + expect(() => _schema.validate(_matching()), returnsNormally); + }); + + test('rejects an extra type group', () { + // F-038 worst-case scenario at the group level: backend smuggles in a + // new top-level type (`Secret`) the client never reviewed. + final backend = _matching(); + backend['Secret'] = [ + {'name': 'hidden', 'type': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA() + .having((e) => e.driftedField, 'driftedField', 'Secret') + .having((e) => e.schemaVersion, 'schemaVersion', 'test/v1'), + ), + ); + }); + + test('rejects a missing type group', () { + // Schema downgrade attempt — backend drops a group the client expects. + // Without the missing-check the client would build a typed-data with + // an empty group and sign a degenerate hash. + final backend = _matching(); + backend.remove('Foo'); + expect( + () => _schema.validate(backend), + throwsA( + isA().having((e) => e.driftedField, 'driftedField', 'Foo'), + ), + ); + }); + + test('rejects an extra field within a group', () { + // F-038 exact attack: extra field `{secretApproval, uint256}` in a + // group the user thinks they reviewed. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'alpha', 'type': 'string'}, + {'name': 'beta', 'type': 'uint256'}, + {'name': 'secretApproval', 'type': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('3 fields, expected 2'), + ), + ), + ); + }); + + test('rejects a missing field within a group', () { + // Backend silently drops a field the schema expects — sign would + // succeed against a shorter type string, but the backend stored hash + // would mismatch. Reject up front. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'alpha', 'type': 'string'}, + ]; + expect( + () => _schema.validate(backend), + throwsA(isA()), + ); + }); + + test('rejects a reordered field list', () { + // EIP-712 hashes the type string left-to-right: swapping field order + // produces a different `encodeType` and therefore a different hash. + // A backend that reorders would silently produce a different signed + // payload — reject so the client never signs the reorder. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'beta', 'type': 'uint256'}, + {'name': 'alpha', 'type': 'string'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'Foo[0].name', + ), + ), + ); + }); + + test('rejects a renamed field', () { + // Same position, same type, different name — different `encodeType` + // string, different hash. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'alpha', 'type': 'string'}, + {'name': 'gamma', 'type': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'Foo[1].name', + ), + ), + ); + }); + + test('rejects a wrong type on a field', () { + // `uint256` vs `int256` is a different solidity type — same name, + // different ABI signature, different hash. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'alpha', 'type': 'string'}, + {'name': 'beta', 'type': 'int256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'Foo[1].type', + ), + ), + ); + }); + + test('rejects an extra key beyond {name, type}', () { + // solc emits `internalType` alongside `name`/`type`; some EIP-712 + // libs treat it as a no-op decoration. We refuse anything beyond the + // two-key shape because (a) the JSON the backend SIGNS would + // potentially include those extra keys, (b) extending the accepted + // shape erodes the byte-equality contract for future fields. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'alpha', 'type': 'string'}, + {'name': 'beta', 'type': 'uint256', 'internalType': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('extra keys'), + ), + ), + ); + }); + + test('rejects a non-string name', () { + // A backend returning `{name: 42, type: "string"}` is malformed; we + // refuse the request instead of letting the typed-data builder + // coerce a non-string into something signable. + final backend = _matching(); + backend['Foo'] = [ + {'name': 'alpha', 'type': 'string'}, + {'name': 42, 'type': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA(isA()), + ); + }); + + test('rejects a non-list type group', () { + // Defensive: the backend returns an object where a list was expected. + // Without this guard the cast `raw as List` would crash with a + // generic CastError instead of a typed drift exception. + final backend = _matching(); + backend['Foo'] = {'alpha': 'string'}; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('is not a list'), + ), + ), + ); + }); + + test('rejects a non-map field entry', () { + final backend = _matching(); + backend['Foo'] = [ + 'alpha:string', + {'name': 'beta', 'type': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('not a {name,type} map'), + ), + ), + ); + }); + + test('property: validate accepts iff backend == pinned (per field)', () { + // Mutates each field one at a time and asserts validate rejects. + // Acts as a generated fuzz for the comparator's per-cell sensitivity + // without resorting to a separate fast-check/glados dependency. + for (var groupIndex = 0; groupIndex < _schema.types.length; groupIndex++) { + final groupName = _schema.types.keys.elementAt(groupIndex); + final fields = _schema.types[groupName]!; + for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) { + final mutated = _matching(); + final list = (mutated[groupName] as List) + .map((e) => Map.from(e as Map)) + .toList(); + // Flip the `name` of the field at (groupIndex, fieldIndex). + list[fieldIndex] = { + 'name': '${list[fieldIndex]['name']}_MUTATED', + 'type': list[fieldIndex]['type'], + }; + mutated[groupName] = list; + expect( + () => _schema.validate(mutated), + throwsA(isA()), + reason: 'must reject mutation at $groupName[$fieldIndex].name', + ); + } + } + }); + + test('typesAsJson() round-trips into a wire-format map', () { + // The wire form the signer hands to eth_sig_util is + // `Map>>`. typesAsJson() builds it + // from the pinned schema (not from the backend response — that's the + // whole point of pinning), so callers don't have a chance to leak + // backend-supplied fields into the signed envelope. + final wire = _schema.typesAsJson(); + expect(wire['Foo'], [ + {'name': 'alpha', 'type': 'string'}, + {'name': 'beta', 'type': 'uint256'}, + ]); + expect(wire['EIP712Domain']!.length, 2); + }); + }); +} diff --git a/test/packages/wallet/schemas/eip7702_delegation_schema_test.dart b/test/packages/wallet/schemas/eip7702_delegation_schema_test.dart new file mode 100644 index 00000000..26018e6d --- /dev/null +++ b/test/packages/wallet/schemas/eip7702_delegation_schema_test.dart @@ -0,0 +1,190 @@ +// Tier-0 tests for the EIP-7702 delegation schema. +// +// Three drift scenarios that map directly to F-038 attack surfaces: +// 1. extra-field drift — backend smuggles `secretApproval` into Delegation +// 2. missing-field drift — backend drops `salt` from Delegation +// 3. reordered-field drift — backend swaps `delegate` and `delegator` +// +// Also a couple of structural pins: +// - The Delegation primary type matches the MetaMask Delegation Framework +// v1.3.0 shape (5 fields, in order). +// - The Caveat sub-type is pinned (2 fields, in order). +// - The domain has chainId + verifyingContract (no F-041 escape hatch +// for EIP-7702; this domain must always carry the chain binding). + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/eip7702_delegation_schema.dart'; + +const _schema = Eip7702DelegationSchema(); + +Map _matchingTypes() => + jsonDecode(jsonEncode(_schema.typesAsJson())) as Map; + +void main() { + group('Eip7702DelegationSchema', () { + test('primary type and version', () { + expect(_schema.primaryType, 'Delegation'); + expect(_schema.schemaVersion, 'eip7702-delegation/v1'); + }); + + test('Delegation has exactly the 5 MetaMask Delegation Framework fields', () { + // v1.3.0 of the framework defines: + // Delegation(address delegate, + // address delegator, + // bytes32 authority, + // Caveat[] caveats, + // uint256 salt) + // Any drift from this shape breaks compatibility with the on-chain + // verifier — pin the contract here so a refactor cannot silently + // misalign. + final delegation = _schema.types['Delegation']!; + expect(delegation.map((f) => '${f.name}:${f.type}'), [ + 'delegate:address', + 'delegator:address', + 'authority:bytes32', + 'caveats:Caveat[]', + 'salt:uint256', + ]); + }); + + test('Caveat is pinned as 2 fields (enforcer, terms)', () { + // The Caveat sub-type is the most likely place for a malicious + // backend to smuggle in an extra field — the user can't see + // individual caveats in the validate-UI today (just a count + the + // visible amount), so pinning Caveat's shape is the defence. + final caveat = _schema.types['Caveat']!; + expect(caveat.map((f) => '${f.name}:${f.type}'), [ + 'enforcer:address', + 'terms:bytes', + ]); + }); + + test('domain carries chainId + verifyingContract', () { + final domain = _schema.types['EIP712Domain']!; + expect(domain.map((f) => f.name), [ + 'name', + 'version', + 'chainId', + 'verifyingContract', + ]); + }); + + test('extra-field drift detection (F-038 attack: secretApproval injected)', () { + // Exact F-038 worst-case: backend adds an opaque uint256 field the + // user never reviewed. The pipeline must refuse to sign before any + // byte hits the BitBox plugin. + final backend = _matchingTypes(); + backend['Delegation'] = [ + ...(backend['Delegation'] as List).cast>(), + {'name': 'secretApproval', 'type': 'uint256'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA() + .having((e) => e.driftedField, 'driftedField', 'Delegation') + .having((e) => e.schemaVersion, 'schemaVersion', 'eip7702-delegation/v1') + .having((e) => e.reason, 'reason', contains('6 fields, expected 5')), + ), + ); + }); + + test('missing-field drift detection (Delegation drops salt)', () { + // Backend silently drops `salt`; if the client built the typed-data + // from the backend response, the on-chain verifier (which expects + // the salt field for replay protection) would reject the signature. + // We refuse to even start signing — the salt drop is itself the + // signal that something is wrong. + final backend = _matchingTypes(); + backend['Delegation'] = (backend['Delegation'] as List) + .cast>() + .where((f) => f['name'] != 'salt') + .toList(); + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + contains('4 fields, expected 5'), + ), + ), + ); + }); + + test('reordered-field drift detection (Delegation swaps delegate/delegator)', () { + // EIP-712 hash is order-sensitive. Swapping `delegate` and + // `delegator` produces a fundamentally different `encodeType` + // string. A malicious backend that re-orders fields while keeping + // the same names would produce a signed payload that the on-chain + // verifier interprets with the operator's intent reversed — + // catastrophic. + final backend = _matchingTypes(); + final fields = (backend['Delegation'] as List).cast>(); + // Swap [0] and [1] — delegate and delegator. + final swapped = [fields[1], fields[0], ...fields.skip(2)]; + backend['Delegation'] = swapped; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'Delegation[0].name', + ), + ), + ); + }); + + test('extra-group drift (backend adds a top-level type the client never reviewed)', () { + // A subtler attack: backend adds a sibling top-level type + // (e.g. `Permit`) and references it from a smuggled `Delegation` + // field. We never enumerated `Permit` → reject the entire envelope. + final backend = _matchingTypes(); + backend['Permit'] = [ + {'name': 'owner', 'type': 'address'}, + {'name': 'spender', 'type': 'address'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'Permit', + ), + ), + ); + }); + + test('Caveat shape drift (extra field on the sub-type)', () { + // The Caveat shape is the per-caveat trust boundary; a smuggled + // field here would attach an unreviewed condition to every caveat + // the user signs. + final backend = _matchingTypes(); + backend['Caveat'] = [ + ...(backend['Caveat'] as List).cast>(), + {'name': 'exempt', 'type': 'bool'}, + ]; + expect( + () => _schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'Caveat', + ), + ), + ); + }); + + test('happy-path: byte-equal backend response is accepted', () { + // Control case: the backend response equals the pinned schema. No + // exception — otherwise every legit sign would drift-reject. + expect(() => _schema.validate(_matchingTypes()), returnsNormally); + }); + }); +} diff --git a/test/packages/wallet/schemas/kyc_sign_schema_test.dart b/test/packages/wallet/schemas/kyc_sign_schema_test.dart new file mode 100644 index 00000000..35d3a8a9 --- /dev/null +++ b/test/packages/wallet/schemas/kyc_sign_schema_test.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/kyc_sign_schema.dart'; + +void main() { + group('KycSignSchema', () { + test('primary type, version and fields are pinned', () { + final schemaFactory = KycSignSchema.new; + final schema = schemaFactory(); + expect(schema.schemaVersion, 'kyc/v1'); + expect(schema.primaryType, 'RealUnitKyc'); + expect(schema.types['EIP712Domain']!.map((f) => '${f.name}:${f.type}'), [ + 'name:string', + 'version:string', + 'chainId:uint256', + 'verifyingContract:address', + ]); + expect(schema.types['RealUnitKyc']!.map((f) => '${f.name}:${f.type}'), [ + 'accountType:string', + 'firstName:string', + 'lastName:string', + 'phone:string', + 'addressStreet:string', + 'addressHouseNumber:string', + 'addressZip:string', + 'addressCity:string', + 'addressCountry:uint256', + 'walletAddress:address', + 'registrationDate:string', + ]); + }); + + test('accepts the byte-equal client-pinned schema', () { + final schemaFactory = KycSignSchema.new; + final schema = schemaFactory(); + final backend = jsonDecode(jsonEncode(schema.typesAsJson())) as Map; + expect(() => schema.validate(backend), returnsNormally); + }); + + test('rejects backend shape drift', () { + final schemaFactory = KycSignSchema.new; + final schema = schemaFactory(); + final backend = jsonDecode(jsonEncode(schema.typesAsJson())) as Map; + final kyc = (backend['RealUnitKyc'] as List).cast>(); + kyc.add({'name': 'hiddenApproval', 'type': 'uint256'}); + backend['RealUnitKyc'] = kyc; + expect( + () => schema.validate(backend), + throwsA( + isA().having( + (e) => e.driftedField, + 'driftedField', + 'RealUnitKyc', + ), + ), + ); + }); + }); +} diff --git a/test/packages/wallet/schemas/registration_schema_test.dart b/test/packages/wallet/schemas/registration_schema_test.dart new file mode 100644 index 00000000..46ae29c4 --- /dev/null +++ b/test/packages/wallet/schemas/registration_schema_test.dart @@ -0,0 +1,159 @@ +// Tier-0 tests for the registration EIP-712 schemas (V1 + V0-legacy). +// +// These tests pin: +// - the byte-stable representation of the schema constant — a future +// refactor that reorders fields or renames a key will turn red here +// before it ships +// - the V1-includes-chainId invariant (F-041 fix) +// - the typesAsJson() output the signer hands to eth_sig_util +// - drift detection on a representative attack payload +// +// Why pin the byte-stable representation: +// Schema = trust root. If the schema bytes drift between releases without +// a coordinated backend rollout, every existing user's stored EIP-712 +// hash diverges from what the new client signs and renewals break. The +// test below uses `serialise()`-style JSON of the schema for stability; +// it does NOT use Object.hashCode (which is salted per VM). + +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/eip712_schema_drift_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/registration_schema.dart'; + +void main() { + group('RegistrationSchemaV1', () { + const schema = RegistrationSchemaV1(); + + test('exposes the EIP-712 RealUnitUser primary type', () { + final schemaFactory = RegistrationSchemaV1.new; + final runtimeSchema = schemaFactory(); + expect(runtimeSchema.schemaVersion, 'registration/v1'); + expect(schema.primaryType, 'RealUnitUser'); + expect(schema.schemaVersion, 'registration/v1'); + }); + + test('domain includes chainId + verifyingContract (F-041 fix)', () { + // Initiative II closes F-041 by including `chainId` (cross-chain + // replay protection) and `verifyingContract` (per-backend isolation) + // in the registration domain. If a refactor removes either, this + // test fails immediately — backend coordination is required and + // this guard is the contract that flags it. + final domain = schema.types['EIP712Domain']!; + expect(domain.map((f) => f.name), [ + 'name', + 'version', + 'chainId', + 'verifyingContract', + ]); + expect(domain.where((f) => f.name == 'chainId').single.type, 'uint256'); + expect( + domain.where((f) => f.name == 'verifyingContract').single.type, + 'address', + ); + }); + + test('RealUnitUser fields are exactly the 13 V1 fields, in order', () { + // EIP-712 hash depends on the order of fields. Pinning the exact + // sequence (and `swissTaxResidence` typed as `bool`, F-002) means a + // reorder or typo turns the build red before the backend stops + // accepting the signed payload. + final user = schema.types['RealUnitUser']!; + expect(user.map((f) => '${f.name}:${f.type}'), [ + 'email:string', + 'name:string', + 'type:string', + 'phoneNumber:string', + 'birthday:string', + 'nationality:string', + 'addressStreet:string', + 'addressPostalCode:string', + 'addressCity:string', + 'addressCountry:string', + 'swissTaxResidence:bool', + 'registrationDate:string', + 'walletAddress:address', + ]); + }); + + test('byte-stable JSON representation', () { + // Stable JSON serialisation of the schema. If the constant ever + // drifts (field reorder, type swap), this snapshot is the first + // line of defence — the test fails BEFORE any deployment. + final wire = schema.typesAsJson(); + final snapshot = jsonEncode(wire); + expect( + snapshot, + '{"EIP712Domain":' + '[{"name":"name","type":"string"},' + '{"name":"version","type":"string"},' + '{"name":"chainId","type":"uint256"},' + '{"name":"verifyingContract","type":"address"}],' + '"RealUnitUser":' + '[{"name":"email","type":"string"},' + '{"name":"name","type":"string"},' + '{"name":"type","type":"string"},' + '{"name":"phoneNumber","type":"string"},' + '{"name":"birthday","type":"string"},' + '{"name":"nationality","type":"string"},' + '{"name":"addressStreet","type":"string"},' + '{"name":"addressPostalCode","type":"string"},' + '{"name":"addressCity","type":"string"},' + '{"name":"addressCountry","type":"string"},' + '{"name":"swissTaxResidence","type":"bool"},' + '{"name":"registrationDate","type":"string"},' + '{"name":"walletAddress","type":"address"}]}', + ); + }); + + test('accepts a matching backend response', () { + final backend = jsonDecode(jsonEncode(schema.typesAsJson())) as Map; + expect(() => schema.validate(backend), returnsNormally); + }); + + test('rejects a smuggled `swissTaxResidence: string` reshape', () { + // F-002 lurking attack: backend silently types swissTaxResidence as + // string ("true" / "false" / "ja") instead of bool. The signed hash + // changes, and a string-typed boolean attestation is also less + // legally clear-cut. Reject. + final backend = jsonDecode(jsonEncode(schema.typesAsJson())) as Map; + final user = (backend['RealUnitUser'] as List).cast>(); + final idx = user.indexWhere((f) => f['name'] == 'swissTaxResidence'); + user[idx] = {'name': 'swissTaxResidence', 'type': 'string'}; + backend['RealUnitUser'] = user; + expect( + () => schema.validate(backend), + throwsA( + isA() + .having((e) => e.driftedField, 'driftedField', 'RealUnitUser[10].type') + .having((e) => e.reason, 'reason', contains('swissTaxResidence')), + ), + ); + }); + }); + + group('RegistrationSchemaV0 (legacy fallback)', () { + const schema = RegistrationSchemaV0(); + + test('exposes the legacy RealUnitUser primary type and schema version', () { + final schemaFactory = RegistrationSchemaV0.new; + final runtimeSchema = schemaFactory(); + expect(runtimeSchema.primaryType, 'RealUnitUser'); + expect(runtimeSchema.schemaVersion, 'registration/v0-legacy'); + }); + + test('domain has no chainId / verifyingContract', () { + // V0 = pre-F-041 backend; kept available behind an explicit opt-in + // for the rollout window. Once the backend is upgraded, V0 is + // removed in a follow-up commit. + final domain = schema.types['EIP712Domain']!; + expect(domain.map((f) => f.name), ['name', 'version']); + }); + + test('RealUnitUser field list matches V1', () { + // The only difference between V0 and V1 is the EIP712Domain; the + // user fields are stable. Asserts the property so a V0/V1 swap is + // safe inside the pipeline at the field-level. + expect(schema.types['RealUnitUser']!, const RegistrationSchemaV1().types['RealUnitUser']!); + }); + }); +} diff --git a/test/packages/wallet/sign_pipeline_test.dart b/test/packages/wallet/sign_pipeline_test.dart new file mode 100644 index 00000000..88bfe1fb --- /dev/null +++ b/test/packages/wallet/sign_pipeline_test.dart @@ -0,0 +1,604 @@ +// Tier-0 contract test for [SignPipeline]. +// +// Pins the architectural promise (ADR 0002): EVERY sign flow funnels +// through the same pipeline, and every entrypoint honours the +// pipeline-step contract. +// +// Six entrypoints exercised: +// +// 1. RegistrationSignRequest — typed-data registration sign +// 2. KycSignRequest — standalone KYC sign +// 3. SellSignRequest — EIP-7702 sell delegation sign +// 4. Eip7702SignRequest — generic EIP-7702 (same shape as Sell) +// 5. BtcPsbtSignRequest — PSBT pre-flight + (todo) submit +// 6. EthTransferSignRequest — raw ETH transfer (EIP-1559 + legacy) +// +// Property test pinned: +// +// pipeline(s).envelope == pipeline(s).dto byte-equal post-romanise +// +// Adversarial vectors: +// +// - non-ASCII in any user string is romanised the same way in +// envelope AND dto (closes F-019) +// - unknown native error code surfaces as BitboxUnknownException +// - empty required field raises SignRequestValidationException +// - cubits switching on `SignException` see typed errors — no +// `e.toString()` matching needed + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/utils/ascii_transliterate.dart'; +import 'package:realunit_wallet/packages/wallet/error_mapper.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/schemas/registration_schema.dart'; +import 'package:realunit_wallet/packages/wallet/sign_pipeline.dart'; +import 'package:web3dart/crypto.dart'; +import 'package:web3dart/web3dart.dart'; + +const _privateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; +// Derived from _privateKeyHex via EthPrivateKey.fromHex(...).address.hexEip55. +const _testAddress = '0xD29C323DfD441E5157F5a05ccE6c74aC94c57aAd'; +const _verifyingContract = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; + +EthPrivateKey _credentials() => EthPrivateKey.fromHex(_privateKeyHex); + +RegistrationSignRequest _registrationReq({ + String email = 'pipeline@dfx.swiss', + String name = 'Pipeline User', + String addressCity = 'Zurich', + bool swissTaxResidence = false, + String registrationDate = '2026-05-23', + int chainId = 1, +}) { + return RegistrationSignRequest( + credentials: _credentials(), + chainId: chainId, + verifyingContract: _verifyingContract, + email: email, + name: name, + type: 'human', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: addressCity, + addressCountry: 'CH', + swissTaxResidence: swissTaxResidence, + registrationDate: registrationDate, + ); +} + +KycSignRequest _kycReq({String firstName = 'Pipeline', String lastName = 'User'}) { + return KycSignRequest( + credentials: _credentials(), + chainId: 1, + verifyingContract: _verifyingContract, + accountType: 'PERSONAL', + firstName: firstName, + lastName: lastName, + phone: '+41790000000', + addressStreet: 'Teststrasse', + addressHouseNumber: '1', + addressZip: '8000', + addressCity: 'Zurich', + addressCountry: 41, + registrationDate: '2026-05-23', + ); +} + +Eip7702Data _validEip7702Data({ + List? delegation, + Eip7702Domain? domain, + Eip7702Message? message, + String amountWei = '1000000000000000000', +}) { + return Eip7702Data( + relayerAddress: '0x0000000000000000000000000000000000000abc', + delegationManagerAddress: _verifyingContract, + delegatorAddress: _testAddress, + userNonce: BigInt.zero, + domain: + domain ?? + const Eip7702Domain( + name: 'DelegationManager', + version: '1', + chainId: 1, + verifyingContract: _verifyingContract, + ), + types: Eip7702Types( + delegation: + delegation ?? + const [ + Eip7702TypeField(name: 'delegate', type: 'address'), + Eip7702TypeField(name: 'delegator', type: 'address'), + Eip7702TypeField(name: 'authority', type: 'bytes32'), + Eip7702TypeField(name: 'caveats', type: 'Caveat[]'), + Eip7702TypeField(name: 'salt', type: 'uint256'), + ], + caveat: const [ + Eip7702TypeField(name: 'enforcer', type: 'address'), + Eip7702TypeField(name: 'terms', type: 'bytes'), + ], + ), + message: + message ?? + Eip7702Message( + delegate: '0x0000000000000000000000000000000000000abc', + delegator: _testAddress, + authority: '0x0000000000000000000000000000000000000000000000000000000000000000', + caveats: [], + salt: BigInt.zero, + ), + tokenAddress: '0x0000000000000000000000000000000000000aaa', + amountWei: amountWei, + depositAddress: '0x0000000000000000000000000000000000000bbb', + ); +} + +Eip7702SignRequest _eip7702Req({Eip7702Data? data}) { + return Eip7702SignRequest( + credentials: _credentials(), + eip7702Data: data ?? _validEip7702Data(), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ); +} + +SellSignRequest _sellReq() { + return SellSignRequest( + credentials: _credentials(), + eip7702Data: _validEip7702Data(), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 1, + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ); +} + +BtcPsbtSignRequest _psbtReq({Uint8List? bytes}) { + // Minimal valid PSBT — magic bytes + a trailing terminator byte to + // satisfy the 5+ byte length floor. Production PSBTs are bigger; the + // pipeline only enforces the magic-byte pre-flight here. + return BtcPsbtSignRequest( + credentials: _credentials(), + psbtBytes: bytes ?? Uint8List.fromList([0x70, 0x73, 0x62, 0x74, 0xff, 0x00]), + ); +} + +EthTransferSignRequest _ethReq({ + bool isEIP1559 = true, + List? payload, + int chainId = 1, + CredentialsWithKnownAddress? credentials, +}) { + return EthTransferSignRequest( + credentials: credentials ?? _credentials(), + payload: Uint8List.fromList( + payload ?? [if (isEIP1559) 0x02, 0xaa, 0xbb, 0xcc, 0xdd], + ), + chainId: chainId, + isEIP1559: isEIP1559, + ); +} + +class _ThrowingCredentials extends CredentialsWithKnownAddress { + final Object error; + + _ThrowingCredentials(this.error); + + @override + EthereumAddress get address => EthereumAddress.fromHex(_testAddress); + + @override + MsgSignature signToEcSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) { + throw UnimplementedError(); + } + + @override + Future signToSignature( + Uint8List payload, { + int? chainId, + bool isEIP1559 = false, + }) { + return Future.error(error); + } + + @override + Future signPersonalMessage(Uint8List payload, {int? chainId}) { + throw UnimplementedError(); + } + + @override + Uint8List signPersonalMessageToUint8List(Uint8List payload, {int? chainId}) { + throw UnimplementedError(); + } +} + +void main() { + const pipeline = SignPipeline(); + + group('SignPipeline: six entrypoints all succeed', () { + test('1. RegistrationSignRequest → TypedDataSignResult with non-empty signature', () async { + final result = await pipeline.sign(_registrationReq()); + expect(result, isA()); + final typed = result as TypedDataSignResult; + expect(typed.signature, startsWith('0x')); + expect(typed.envelopeJson, contains('"primaryType":"RealUnitUser"')); + expect(typed.dtoJson, contains('"swissTaxResidence":false')); + }); + + test('2. KycSignRequest → TypedDataSignResult with non-empty signature', () async { + final result = await pipeline.sign(_kycReq()); + expect(result, isA()); + final typed = result as TypedDataSignResult; + expect(typed.signature, startsWith('0x')); + expect(typed.envelopeJson, contains('"primaryType":"RealUnitKyc"')); + }); + + test('3. SellSignRequest → TypedDataSignResult with non-empty signature', () async { + final result = await pipeline.sign(_sellReq()); + expect(result, isA()); + final typed = result as TypedDataSignResult; + expect(typed.signature, startsWith('0x')); + expect(typed.envelopeJson, contains('"primaryType":"Delegation"')); + }); + + test('4. Eip7702SignRequest → TypedDataSignResult with non-empty signature', () async { + final result = await pipeline.sign(_eip7702Req()); + expect(result, isA()); + expect((result as TypedDataSignResult).signature, startsWith('0x')); + }); + + test('5. BtcPsbtSignRequest → BtcPsbtSignResult (magic-byte pre-flight)', () async { + final result = await pipeline.sign(_psbtReq()); + expect(result, isA()); + }); + + test('6. EthTransferSignRequest (EIP-1559) → EthTransferSignResult', () async { + final result = await pipeline.sign(_ethReq(isEIP1559: true)); + expect(result, isA()); + }); + + test('6b. EthTransferSignRequest (legacy) → EthTransferSignResult', () async { + final result = await pipeline.sign(_ethReq(isEIP1559: false)); + expect(result, isA()); + }); + }); + + group('Romanisation invariant (F-019): envelope and dto are byte-equal-equivalent', () { + test('non-ASCII registration name appears identically in envelope and dto', () async { + final result = await pipeline.sign( + _registrationReq( + name: 'Joshua Krüger', + addressCity: 'Zürich', + email: 'pipeline+æø@dfx.swiss', + ), + ); + final typed = result as TypedDataSignResult; + final envelope = jsonDecode(typed.envelopeJson) as Map; + final dto = jsonDecode(typed.dtoJson) as Map; + final envMessage = envelope['message'] as Map; + expect(envMessage['name'], dto['name']); + expect(envMessage['addressCity'], dto['addressCity']); + expect(envMessage['email'], dto['email']); + + // And the romanisation actually happened — no non-ASCII bytes + // anywhere in the signed string. If a future refactor forgets to + // romanise, this catches it. + expect( + (dto['name'] as String).codeUnits.every((u) => u < 128), + isTrue, + reason: 'name still carries non-ASCII bytes — toBitboxSafeAscii skipped', + ); + expect( + (dto['addressCity'] as String).codeUnits.every((u) => u < 128), + isTrue, + ); + }); + + test('KYC firstName + lastName romanised identically in envelope and dto', () async { + final result = await pipeline.sign( + _kycReq(firstName: 'Étienne', lastName: 'Müller-Ångström'), + ); + final typed = result as TypedDataSignResult; + final envelope = jsonDecode(typed.envelopeJson) as Map; + final dto = jsonDecode(typed.dtoJson) as Map; + final envMessage = envelope['message'] as Map; + expect(envMessage['firstName'], dto['firstName']); + expect(envMessage['lastName'], dto['lastName']); + expect( + (dto['lastName'] as String).codeUnits.every((u) => u < 128), + isTrue, + ); + }); + + test('property: every romanised user string is pure ASCII (full alphabet sweep)', () async { + const samples = ['äöüß', 'éàâ', 'ñõ', 'ÆØÅ', 'çž', 'Ł', '«»', '…—']; + for (final s in samples) { + final result = await pipeline.sign( + _registrationReq(name: s, addressCity: s), + ); + final dto = jsonDecode((result as TypedDataSignResult).dtoJson) as Map; + final romanised = dto['name'] as String; + expect( + romanised.codeUnits.every((u) => u < 128), + isTrue, + reason: 'sample "$s" → $romanised still has non-ASCII', + ); + // F3: pure-ASCII is necessary but NOT sufficient — the romaniser's + // last-resort branch replaces an unmappable rune with '?', which IS + // ASCII and so would slip past the check above while silently + // destroying the byte the user signs. None of these samples contains a + // literal '?', so a '?' in the output can only be placeholder loss. + expect( + romanised.contains('?'), + isFalse, + reason: 'sample "$s" → $romanised degraded to the "?" placeholder — ' + 'add its transliteration to ascii_transliterate.dart', + ); + } + }); + + test('F3: the "?" placeholder fallback still fires for a genuinely ' + 'unmappable rune — proving the no-"?" assertion above is meaningful', + () async { + // An emoji has no Latin transliteration, so it must hit the last-resort + // branch. This pins the placeholder contract so the sweep's no-"?" + // assertion cannot be defeated by accidentally removing the fallback. + expect(toBitboxSafeAscii('hi 😀'), 'hi ?'); + }); + }); + + group('Validation contract', () { + test('empty email → SignRequestValidationException(field=email)', () async { + final req = RegistrationSignRequest( + credentials: _credentials(), + chainId: 1, + verifyingContract: _verifyingContract, + email: '', + name: 'X', + type: 'human', + phoneNumber: '+1', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'X', + addressPostalCode: '1', + addressCity: 'X', + addressCountry: 'CH', + swissTaxResidence: false, + registrationDate: '2026-05-23', + ); + await expectLater( + pipeline.sign(req), + throwsA( + isA().having( + (e) => e.field, + 'field', + 'email', + ), + ), + ); + }); + + test('non-positive chainId → SignRequestValidationException(field=chainId)', () async { + await expectLater( + pipeline.sign(_registrationReq(chainId: 0)), + throwsA( + isA().having( + (e) => e.field, + 'field', + 'chainId', + ), + ), + ); + }); + + test('PSBT magic-byte mismatch → BtcPsbtInvalidException', () async { + await expectLater( + pipeline.sign( + _psbtReq(bytes: Uint8List.fromList([0xff, 0xff, 0xff, 0xff, 0xff])), + ), + throwsA(isA()), + ); + }); + + test('EIP-1559 transfer with payload[0] != 0x02 → Eip1559TypeMismatchException', () async { + await expectLater( + pipeline.sign(_ethReq(payload: [0x01, 0xaa], isEIP1559: true)), + throwsA(isA()), + ); + }); + }); + + group('Schema-pinning contract (F-038)', () { + test('EIP-7702 backend smuggles extra Delegation field → Eip712SchemaDriftException', () async { + final data = _validEip7702Data( + delegation: const [ + Eip7702TypeField(name: 'delegate', type: 'address'), + Eip7702TypeField(name: 'delegator', type: 'address'), + Eip7702TypeField(name: 'authority', type: 'bytes32'), + Eip7702TypeField(name: 'caveats', type: 'Caveat[]'), + Eip7702TypeField(name: 'salt', type: 'uint256'), + // The attack: backend smuggles a hidden caveat + Eip7702TypeField(name: 'secretApproval', type: 'uint256'), + ], + ); + await expectLater( + pipeline.sign(_eip7702Req(data: data)), + throwsA(isA()), + ); + }); + + test('EIP-7702 wrong chainId → Eip7702ExpectedParamsMismatchException', () async { + final req = Eip7702SignRequest( + credentials: _credentials(), + eip7702Data: _validEip7702Data(), + expectedVerifyingContract: _verifyingContract, + expectedChainId: 137, // backend ships 1 + expectedDelegator: _testAddress, + expectedAmount: BigInt.from(10).pow(18), + ); + await expectLater( + pipeline.sign(req), + throwsA(isA()), + ); + }); + + test('EIP-7702 wrong verifyingContract rejects before signing', () async { + final req = _eip7702Req( + data: _validEip7702Data( + domain: const Eip7702Domain( + name: 'DelegationManager', + version: '1', + chainId: 1, + verifyingContract: '0x0000000000000000000000000000000000000001', + ), + ), + ); + await expectLater( + pipeline.sign(req), + throwsA( + isA() + .having((e) => e.parameter, 'parameter', 'verifyingContract') + .having((e) => e.actual, 'actual', '0x0000000000000000000000000000000000000001'), + ), + ); + }); + + test('EIP-7702 wrong delegator rejects before signing', () async { + final req = _eip7702Req( + data: _validEip7702Data( + message: Eip7702Message( + delegate: '0x0000000000000000000000000000000000000abc', + delegator: '0x0000000000000000000000000000000000000002', + authority: '0x0000000000000000000000000000000000000000000000000000000000000000', + caveats: [], + salt: BigInt.zero, + ), + ), + ); + await expectLater( + pipeline.sign(req), + throwsA( + isA() + .having((e) => e.parameter, 'parameter', 'delegator') + .having((e) => e.actual, 'actual', '0x0000000000000000000000000000000000000002'), + ), + ); + }); + + test('EIP-7702 wrong amount rejects before signing', () async { + final req = _eip7702Req(data: _validEip7702Data(amountWei: '2')); + await expectLater( + pipeline.sign(req), + throwsA( + isA() + .having((e) => e.parameter, 'parameter', 'amountWei') + .having((e) => e.actual, 'actual', '2'), + ), + ); + }); + + test('EIP-7702 unparsable amount rejects before signing', () async { + final req = _eip7702Req(data: _validEip7702Data(amountWei: 'not-a-number')); + await expectLater( + pipeline.sign(req), + throwsA( + isA() + .having((e) => e.parameter, 'parameter', 'amountWei') + .having((e) => e.actual, 'actual', 'not-a-number'), + ), + ); + }); + }); + + group('Error boundary', () { + test('legacy SigningCancelledException is mapped to a typed sign exception', () async { + await expectLater( + pipeline.sign( + _ethReq(credentials: _ThrowingCredentials(const SigningCancelledException())), + ), + throwsA(isA()), + ); + }); + + test('legacy BitboxNotConnectedException is mapped to a typed sign exception', () async { + await expectLater( + pipeline.sign( + _ethReq(credentials: _ThrowingCredentials(const BitboxNotConnectedException())), + ), + throwsA(isA()), + ); + }); + + test('unknown plugin exception is mapped to BitboxUnknownException', () async { + await expectLater( + pipeline.sign(_ethReq(credentials: _ThrowingCredentials(const FormatException('bad sig')))), + throwsA( + isA() + .having((e) => e.rawCode, 'rawCode', -1) + .having((e) => e.message, 'message', contains('bad sig')), + ), + ); + }); + }); + + group('Pipeline-step ordering', () { + test('non-ASCII in registration name does NOT cause a chainId validation failure', () async { + // Step ordering: validate runs first (positive chainId OK), + // romanise runs after — the romanised name is what the signer + // sees. Pinning that this ordering does not collapse into a + // different exception type the cubit can't recognise. + final result = await pipeline.sign( + _registrationReq(name: 'Müller', chainId: 1), + ); + expect(result, isA()); + }); + }); + + group('SignResult shape: envelope and dto carry the post-romanise canonical bytes', () { + test( + 'registration: dto JSON has the romanised name and the walletAddress is unchanged', + () async { + final result = await pipeline.sign(_registrationReq(name: 'Müller')); + final dto = jsonDecode((result as TypedDataSignResult).dtoJson) as Map; + expect(dto['name'], 'Mueller'); + expect(dto['walletAddress'], _testAddress); + }, + ); + + test('schemaVersion is reflected in the envelope primaryType', () async { + const schema = RegistrationSchemaV1(); + final req = RegistrationSignRequest( + credentials: _credentials(), + chainId: 1, + verifyingContract: _verifyingContract, + email: 'a@b.c', + name: 'X', + type: 'human', + phoneNumber: '+1', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'X', + addressPostalCode: '1', + addressCity: 'X', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-05-23', + schema: schema, + ); + final result = await pipeline.sign(req); + final envelope = + jsonDecode((result as TypedDataSignResult).envelopeJson) as Map; + expect(envelope['primaryType'], schema.primaryType); + }); + }); +} diff --git a/test/screens/sell/cubits/sell_confirm_cubit_test.dart b/test/screens/sell/cubits/sell_confirm_cubit_test.dart index 8dcde61b..5b6e624f 100644 --- a/test/screens/sell/cubits/sell_confirm_cubit_test.dart +++ b/test/screens/sell/cubits/sell_confirm_cubit_test.dart @@ -14,26 +14,26 @@ class _MockSellPaymentInfoService extends Mock class _FakeSellPaymentInfo extends Fake implements SellPaymentInfo {} -SellPaymentInfo _stubPaymentInfo() => const SellPaymentInfo( +SellPaymentInfo _stubPaymentInfo() => SellPaymentInfo( id: 1, eip7702: Eip7702Data( relayerAddress: '0x1', delegationManagerAddress: '0x2', delegatorAddress: '0x3', - userNonce: 0, - domain: Eip7702Domain( + userNonce: BigInt.zero, + domain: const Eip7702Domain( name: 'RealUnit', version: '1', chainId: 1, verifyingContract: '0x4', ), - types: Eip7702Types(delegation: [], caveat: []), + types: const Eip7702Types(delegation: [], caveat: []), message: Eip7702Message( delegate: '0x5', delegator: '0x6', authority: '0x7', caveats: [], - salt: 0, + salt: BigInt.zero, ), tokenAddress: '0x8', amountWei: '0', @@ -42,7 +42,7 @@ SellPaymentInfo _stubPaymentInfo() => const SellPaymentInfo( amount: 100, exchangeRate: 1.0, rate: 1.0, - beneficiary: BeneficiaryDto(iban: 'CH56'), + beneficiary: const BeneficiaryDto(iban: 'CH56'), estimatedAmount: 100, currency: Currency.chf, depositAddress: '0xA', diff --git a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart index 1926cb26..f4c11479 100644 --- a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart +++ b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart @@ -28,24 +28,24 @@ SellPaymentInfo _info({ Currency currency = Currency.chf, }) => SellPaymentInfo( id: 1, - eip7702: const Eip7702Data( + eip7702: Eip7702Data( relayerAddress: '0x1', delegationManagerAddress: '0x2', delegatorAddress: '0x3', - userNonce: 0, - domain: Eip7702Domain( + userNonce: BigInt.zero, + domain: const Eip7702Domain( name: 'RealUnit', version: '1', chainId: 1, verifyingContract: '0x4', ), - types: Eip7702Types(delegation: [], caveat: []), + types: const Eip7702Types(delegation: [], caveat: []), message: Eip7702Message( delegate: '0x5', delegator: '0x6', authority: '0x7', caveats: [], - salt: 0, + salt: BigInt.zero, ), tokenAddress: '0x8', amountWei: '0',