diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..765a3ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# eth_url_parser + +## Agent skills + +### Issue tracker + +Issues are tracked in this repo's GitHub Issues (`LiorAgnin/eth_url_parser`) via the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five canonical triage labels are used as-is (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root, created lazily. See `docs/agents/domain.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e3070..3781f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 0.2.0 + +Strict [EIP-681](https://eips.ethereum.org/EIPS/eip-681) compliance. Fixes +silent precision loss on the money path. + +- **Fixed**: amounts are now parsed and formatted with `BigInt` — values above + int64 (~9.22 ETH in wei) no longer lose wei silently through `num.parse` +- **Fixed**: `build` no longer corrupts amounts via lossy + `toStringAsExponential` — trailing zeros compress into an exact exponent + (`2014000000000000000` → `2.014e18`), anything else stays plain decimal +- **Fixed**: addresses are validated as exactly 40 hex digits; non-hex + 40-character strings are rejected, and a `0x` payload never falls back to + ENS (hex takes precedence per the spec) +- **Added**: bare ENS targets (`ethereum:vitalik.eth`, + `ethereum:doge-to-the-moon.eth`) parse per the spec grammar +- **Added**: the scientific-notation number grammar is enforced exactly — + the exponent must be ≥ the number of decimals (`1.5e0` is rejected) +- **Breaking**: only the spec-defined `pay-` prefix is accepted; arbitrary + ERC-831 prefixes (`foo-`) now throw +- **Breaking**: all malformed input throws `FormatException` with a + descriptive message (previously a mix of bare `Exception` and raw + `RangeError` on short input) +- **Breaking**: `QueryString.parse` uses strict RFC 3986 percent-decoding; + `+` is a literal plus (the number grammar's sign), not a space +- Validated `chainId` as decimal digits and the `transfer` `address` + parameter as a valid address or ENS name +- **Breaking**: `EthUrlParser` and `QueryString` can no longer be + instantiated — both are static-only utility classes +- 100% dartdoc coverage of the public API, enforced by the + `public_member_api_docs` lint and strict analyzer language checks + ## 0.1.0 - **Breaking**: Removed `freezed` and `json_serializable` code generation dependencies diff --git a/README.md b/README.md index 91a6f9a..366b277 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ # eth_url_parser -A Dart library for parsing and building Ethereum URLs according to [ERC-681](https://eips.ethereum.org/EIPS/eip-681) and [ERC-831](https://eips.ethereum.org/EIPS/eip-831). +A Dart library for parsing and building Ethereum URLs according to [ERC-681](https://eips.ethereum.org/EIPS/eip-681). -Translated from the JavaScript [eth-url-parser](https://www.npmjs.com/package/eth-url-parser) package. +Originally translated from the JavaScript [eth-url-parser](https://www.npmjs.com/package/eth-url-parser) package. ## Features +- Strict EIP-681 grammar: only the spec-defined `pay-` prefix, addresses of + exactly 40 hex digits, bare ENS targets (`ethereum:vitalik.eth`) +- Exact amount handling via `BigInt` — no precision loss at any magnitude, + in either direction (parse or build) - Parse Ethereum URIs into strongly-typed `TransactionRequest` objects - Build valid Ethereum URIs from `TransactionRequest` objects - Supports ERC-681 parameters: `value`, `gas`, `gasLimit`, `gasPrice` - Supports ERC-20 `transfer` function calls -- ENS name resolution support - Chain ID support +- All malformed input throws `FormatException` with a descriptive message - No code generation required -- pure Dart ## Installation @@ -20,7 +24,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - eth_url_parser: ^0.1.0 + eth_url_parser: ^0.2.0 ``` ## Usage @@ -48,13 +52,13 @@ final uri = EthUrlParser.build( targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', functionName: 'transfer', parameters: { - 'address': '0x12345', + 'address': '0x8e23ee67d1332ad560396262c48ffbb01f93d052', 'uint256': '1', }, ), ); print(uri); -// ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD/transfer?address=0x12345&uint256=1 +// ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1 ``` ### The `TransactionRequest` model @@ -64,7 +68,7 @@ TransactionRequest( scheme: 'ethereum', // default targetAddress: '0x...', // required functionName: 'transfer', // optional - prefix: 'pay', // optional (ERC-831) + prefix: 'pay', // optional ('pay' is the only EIP-681 prefix) chainId: 1, // optional parameters: { // optional 'value': '1000000000000000000', @@ -78,8 +82,12 @@ TransactionRequest( ### `EthUrlParser.parse(String uri)` Parses an Ethereum URI string and returns a `TransactionRequest`. -Throws an `Exception` for invalid URIs. +Throws a `FormatException` for invalid URIs. ### `EthUrlParser.build(TransactionRequest request)` Builds an Ethereum URI string from a `TransactionRequest` object. +Amounts with at least three trailing zeros are emitted in the exponent +notation the EIP suggests (`2.014e18`); all others stay plain decimal, so +round-trips never lose a wei. Throws a `FormatException` for an invalid +prefix, target address, or amount. diff --git a/analysis_options.yaml b/analysis_options.yaml index 9daba89..de49765 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -13,11 +13,15 @@ include: package:lints/recommended.yaml -# Uncomment the following section to specify additional rules. +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true -# linter: -# rules: -# - camel_case_types +linter: + rules: + - public_member_api_docs # For more information about the core and recommended set of lints, see # https://dart.dev/go/core-lints diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..b548c53 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..bf595e2 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me` — the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/example/eth_url_parser_example.dart b/example/eth_url_parser_example.dart index ff7fb40..c0f200d 100644 --- a/example/eth_url_parser_example.dart +++ b/example/eth_url_parser_example.dart @@ -10,12 +10,12 @@ void main() { // Parse a URL with parameters final transferRequest = EthUrlParser.parse( - 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD/transfer?address=0xABCD&uint256=1', + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1', ); print('Function: ${transferRequest.functionName}'); // Function: transfer print('Parameters: ${transferRequest.parameters}'); - // Parameters: {address: 0xABCD, uint256: 1} + // Parameters: {address: 0x8e23ee67d1332ad560396262c48ffbb01f93d052, uint256: 1} // Build an Ethereum URL from a TransactionRequest final uri = EthUrlParser.build( @@ -24,11 +24,11 @@ void main() { functionName: 'transfer', chainId: 1, parameters: { - 'address': '0xABCD', + 'address': '0x8e23ee67d1332ad560396262c48ffbb01f93d052', 'uint256': '1', }, ), ); print('Built URI: $uri'); - // Built URI: ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD@1/transfer?address=0xABCD&uint256=1 + // Built URI: ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD@1/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1 } diff --git a/lib/eth_url_parser.dart b/lib/eth_url_parser.dart index 43af6b8..385a1ae 100644 --- a/lib/eth_url_parser.dart +++ b/lib/eth_url_parser.dart @@ -1,2 +1,11 @@ +/// A Dart library for parsing and building Ethereum URIs according to +/// [EIP-681](https://eips.ethereum.org/EIPS/eip-681). +/// +/// Parse a payment URI into a strongly-typed [TransactionRequest] with +/// [EthUrlParser.parse], or build one back into a URI with +/// [EthUrlParser.build]. All amounts are handled exactly via [BigInt] — +/// no wei is ever lost to floating-point precision. +library; + export 'src/models/models.dart'; export 'src/eth_url_parser_base.dart'; diff --git a/lib/src/eth_url_parser_base.dart b/lib/src/eth_url_parser_base.dart index 340705d..d0fc288 100644 --- a/lib/src/eth_url_parser_base.dart +++ b/lib/src/eth_url_parser_base.dart @@ -3,10 +3,18 @@ import 'package:eth_url_parser/src/query_string.dart'; /// A Dart library for parsing and building Ethereum URIs as described in [EIP-681](https://eips.ethereum.org/EIPS/eip-681). class EthUrlParser { - /// Parse an Ethereum URI according to ERC-831 and ERC-681 + // Static-only utility class; not meant to be instantiated. + EthUrlParser._(); + + /// Parses an Ethereum URI according to EIP-681. + /// + /// Amounts (`value`, or `uint256` for `transfer` calls, plus `gas`, + /// `gasLimit` and `gasPrice`) are normalized to plain decimal wei strings + /// using exact [BigInt] math, so scientific notation like `2.014e18` never + /// loses precision. /// - /// Throws an [Exception] if the given [uri] is not a valid Ethereum URI or if - /// it cannot be parsed. + /// Throws a [FormatException] if the given [uri] is not a valid EIP-681 + /// Ethereum URI. /// /// ```dart /// final TransactionRequest transactionRequest = EthUrlParser.parse( @@ -15,79 +23,127 @@ class EthUrlParser { /// print(transactionRequest.targetAddress); // "0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD" /// ``` static TransactionRequest parse(String uri) { - if (uri.substring(0, 9) != 'ethereum:') { - throw Exception('Not an Ethereum URI'); + if (!uri.startsWith('ethereum:')) { + throw const FormatException('Not an Ethereum URI'); } + var rest = uri.substring('ethereum:'.length); + // EIP-681: schema_prefix = "ethereum" ":" [ "pay-" ] — "pay" is the only + // prefix the spec defines. Anything else is part of the target (ENS). String? prefix; - String addressRegex = '(0x[\\w]{40})'; - - if (uri.substring(9, 11).toLowerCase() == '0x') { - prefix = null; - } else { - final cutOff = uri.indexOf('-', 9); - if (cutOff == -1) { - throw Exception('Missing prefix'); - } - prefix = uri.substring(9, cutOff); - final rest = uri.substring(cutOff + 1); - if (rest.substring(0, 2).toLowerCase() != '0x') { - addressRegex = - '([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9].[a-zA-Z]{2,})'; - } + if (rest.startsWith('pay-')) { + prefix = 'pay'; + rest = rest.substring('pay-'.length); } - final fullRegex = - '^ethereum:($prefix-)?$addressRegex\\@?([\\w]*)*\\/?([\\w]*)*'; - final exp = RegExp(fullRegex); - final List data = exp.allMatches(uri).toList(); - if (data.isEmpty) { - throw Exception("Couldn't not parse the url"); + String paramString = ''; + final queryIndex = rest.indexOf('?'); + if (queryIndex != -1) { + paramString = rest.substring(queryIndex + 1); + rest = rest.substring(0, queryIndex); } - final String paramString = uri.contains('?') ? uri.split('?')[1] : ''; - final Map params = QueryString.parse(paramString); - - final Map obj = { - 'scheme': 'ethereum', - 'targetAddress': data[0].group(2), - }; - - if (prefix != null) { - obj.putIfAbsent('prefix', () => prefix); + String? functionName; + final slashIndex = rest.indexOf('/'); + if (slashIndex != -1) { + functionName = Uri.decodeComponent(rest.substring(slashIndex + 1)); + rest = rest.substring(0, slashIndex); + if (functionName.isEmpty) { + throw const FormatException('Empty function name'); + } } - if (data[0].group(3) != null) { - obj.putIfAbsent('chainId', () => int.parse(data[0].group(3)!)); + int? chainId; + final atIndex = rest.indexOf('@'); + if (atIndex != -1) { + final chainIdString = rest.substring(atIndex + 1); + rest = rest.substring(0, atIndex); + // chain_id = 1*DIGIT + if (!RegExp(r'^\d+$').hasMatch(chainIdString)) { + throw FormatException('Invalid chain id: $chainIdString'); + } + chainId = int.parse(chainIdString); } - if (data[0].group(4) != null) { - obj.putIfAbsent('functionName', () => data[0].group(4)); - } + final targetAddress = _validateTargetAddress(rest); - if (params.isNotEmpty) { - obj.putIfAbsent('parameters', () => Map.from(params)); - final amountKey = obj['functionName'] == 'transfer' ? 'uint256' : 'value'; + final Map params = QueryString.parse(paramString); + final Map parameters = Map.from(params); - if ((obj['parameters'] as Map)[amountKey] != null) { - final num amount = num.parse((obj['parameters'] as Map)[amountKey]); - String value; - if (amount.toString().endsWith('.0')) { - value = amount.toString().split('.').first; - } else { - value = amount.toString(); - } - (obj['parameters'] as Map)[amountKey] = value; - if (!amount.toDouble().isFinite) { - throw Exception('Invalid amount'); - } - if (amount < 0) { - throw Exception('Invalid amount'); + final amountKey = functionName == 'transfer' ? 'uint256' : 'value'; + for (final key in [amountKey, 'gas', 'gasLimit', 'gasPrice']) { + final raw = parameters[key]; + if (raw != null) { + final BigInt amount = _parseNumber(raw as String); + if (amount < BigInt.zero) { + throw FormatException('Invalid amount: $raw must not be negative'); } + parameters[key] = amount.toString(); } } + if (parameters['address'] != null) { + _validateTargetAddress(parameters['address'] as String); + } - return TransactionRequest.fromJson(obj); + return TransactionRequest( + scheme: 'ethereum', + targetAddress: targetAddress, + prefix: prefix, + chainId: chainId, + functionName: functionName, + parameters: parameters, + ); + } + + /// EIP-681: ethereum_address = ( "0x" 40*HEXDIG ) / ENS_NAME. + /// + /// Hexadecimal addresses take precedence over ENS names, so a target + /// starting with 0x that is not a valid address is an error — never an ENS + /// fallback. Addresses are 20 bytes, i.e. exactly 40 hex digits. + static String _validateTargetAddress(String target) { + if (target.startsWith('0x') || target.startsWith('0X')) { + if (!RegExp(r'^0[xX][0-9a-fA-F]{40}$').hasMatch(target)) { + throw FormatException('Invalid Ethereum address: $target'); + } + return target; + } + // The spec leaves ENS_NAME open; require dot-separated non-empty labels + // of [a-zA-Z0-9-] that don't start or end with a dash. + final label = '[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?'; + if (!RegExp('^$label(\\.$label)+\$').hasMatch(target)) { + throw FormatException( + 'Invalid target: $target is neither an Ethereum address nor an ENS name'); + } + return target; + } + + /// Parses the EIP-681 number grammar exactly, with no precision loss: + /// + /// number = [ "-" / "+" ] *DIGIT [ "." 1*DIGIT ] [ ( "e" / "E" ) [ 1*DIGIT ] ] + /// + /// Only integer numbers are allowed, so the exponent must be greater than + /// or equal to the number of decimals after the point. + static BigInt _parseNumber(String input) { + final match = + RegExp(r'^([+-]?)(\d*)(?:\.(\d+))?(?:[eE](\d*))?$').firstMatch(input); + if (match == null) { + throw FormatException('Invalid number: $input'); + } + final integerDigits = match.group(2)!; + final decimalDigits = match.group(3) ?? ''; + if (integerDigits.isEmpty && decimalDigits.isEmpty) { + throw FormatException('Invalid number: $input'); + } + final exponent = int.parse('0${match.group(4) ?? ''}'); + if (exponent < decimalDigits.length) { + throw FormatException( + 'Invalid number: $input — the exponent must be greater or equal to ' + 'the number of decimals after the point'); + } + final digits = BigInt.parse('0$integerDigits$decimalDigits'); + final magnitude = + digits * BigInt.from(10).pow(exponent - decimalDigits.length); + return match.group(1) == '-' ? -magnitude : magnitude; } /// Builds an Ethereum URI from a [TransactionRequest] object. @@ -95,10 +151,13 @@ class EthUrlParser { /// The [TransactionRequest] object contains all the necessary information to build the URI, /// such as the scheme, target address, function name, and parameters. /// - /// If the [TransactionRequest] object contains any parameters, they will be added to the URI - /// as a query string. The amount parameter will be converted to atomic units if necessary. + /// If the [TransactionRequest] object contains any parameters, they will be + /// added to the URI as a query string. Amounts with at least three trailing + /// zeros are emitted in the exponent notation EIP-681 suggests (`2.014e18`); + /// all others stay plain decimal, so round-trips never lose a wei. /// - /// Throws an [Exception] if the amount parameter is invalid. + /// Throws a [FormatException] if the prefix, target address, or amount is + /// invalid. /// /// ```dart /// final transactionRequest = TransactionRequest( @@ -106,38 +165,36 @@ class EthUrlParser { /// targetAddress: '0x1234567890123456789012345678901234567890', /// functionName: 'transfer', /// parameters: { - /// 'value': '1.23', /// 'address': '0x0987654321098765432109876543210987654321', + /// 'uint256': '1000000000000000000', /// }, /// ); /// final uri = EthUrlParser.build(transactionRequest); /// ``` static String build(TransactionRequest transactionRequest) { + if (transactionRequest.prefix != null && + transactionRequest.prefix != 'pay') { + throw FormatException( + 'Invalid prefix: ${transactionRequest.prefix} — EIP-681 only defines "pay-"'); + } + _validateTargetAddress(transactionRequest.targetAddress); + String? query; if (transactionRequest.parameters.isNotEmpty) { final amountKey = transactionRequest.functionName == 'transfer' ? 'uint256' : 'value'; if (transactionRequest.parameters[amountKey] != null) { - // This is weird. Scientific notation in JS is usually 2.014e+18 - // but the EIP 681 shows no "+" sign ¯\_(ツ)_/¯ - // source: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-681.md#semantics - final num amount = num.parse(transactionRequest.parameters[amountKey]); - final String atomicUnits = amount - .toStringAsExponential() - .replaceAll('+', '') - .replaceAll('e0', ''); + final BigInt amount = + _parseNumber(transactionRequest.parameters[amountKey] as String); + if (amount < BigInt.zero) { + throw FormatException('Invalid amount: $amount must not be negative'); + } transactionRequest = transactionRequest.copyWith( parameters: Map.from({ ...transactionRequest.parameters, - amountKey: atomicUnits, + amountKey: _formatNumber(amount), }), ); - if (!amount.toDouble().isFinite) { - throw Exception('Invalid amount'); - } - if (amount < 0) { - throw Exception('Invalid amount'); - } } query = Uri( queryParameters: transactionRequest.parameters, @@ -148,4 +205,26 @@ class EthUrlParser { return uri; } + + /// Formats an amount losslessly, preferring the exponent notation EIP-681 + /// suggests: trailing zeros compress into an exponent (2014000000000000000 + /// becomes 2.014e18); a number they can't compress stays plain decimal, so + /// no wei is ever dropped. The EIP shows exponents without a '+' sign. + static String _formatNumber(BigInt amount) { + final digits = amount.toString(); + var zeros = 0; + while ( + zeros < digits.length - 1 && digits[digits.length - 1 - zeros] == '0') { + zeros++; + } + if (zeros < 3) { + return digits; + } + final mantissa = digits.substring(0, digits.length - zeros); + final exponent = digits.length - 1; + if (mantissa.length == 1) { + return '${mantissa}e$exponent'; + } + return '${mantissa[0]}.${mantissa.substring(1)}e$exponent'; + } } diff --git a/lib/src/query_string.dart b/lib/src/query_string.dart index 6d12bed..4d778de 100644 --- a/lib/src/query_string.dart +++ b/lib/src/query_string.dart @@ -1,7 +1,13 @@ +/// Parses URI query strings into key/value maps using strict RFC 3986 +/// percent-decoding. class QueryString { + // Static-only utility class; not meant to be instantiated. + QueryString._(); + + /// Parses the given [query] string into a map of decoded keys and values. /// - /// * Parses the given query string into a Map. - /// + /// A leading `?` is ignored. `+` is a literal plus sign, not a space — + /// the EIP-681 number grammar allows a leading `+` on amounts. static Map parse(String query) { final search = RegExp('([^&=]+)=?([^&]*)'); final result = {}; @@ -9,8 +15,9 @@ class QueryString { // Get rid off the beginning ? in query strings. if (query.startsWith('?')) query = query.substring(1); - // A custom decoder. - String decode(String s) => Uri.decodeComponent(s.replaceAll('+', ' ')); + // Strict RFC 3986 percent-decoding: '+' is a literal plus, not a space — + // the EIP-681 number grammar allows a leading '+' sign. + String decode(String s) => Uri.decodeComponent(s); // Go through all the matches and build the result map. for (final match in search.allMatches(query)) { diff --git a/pubspec.yaml b/pubspec.yaml index aa766e4..6db5962 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: eth_url_parser description: A Dart library for parsing and building Ethereum URLs according to - ERC-681 and ERC-831. Supports ERC-20 transfers, ENS names, and chain IDs. -version: 0.1.0 + ERC-681. Supports ERC-20 transfers, ENS names, and chain IDs. +version: 0.2.0 repository: https://github.com/fuseio/eth_url_parser homepage: https://fuse.io topics: diff --git a/test/eth_url_parser_test.dart b/test/eth_url_parser_test.dart index 6259c4f..ccdbb5b 100644 --- a/test/eth_url_parser_test.dart +++ b/test/eth_url_parser_test.dart @@ -31,28 +31,45 @@ void main() { ); }); - test('parses URI with foo prefix', () { + test('rejects non-pay prefix (only pay- is defined by EIP-681)', () { expect( - EthUrlParser.parse( + () => EthUrlParser.parse( 'ethereum:foo-0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', ), + throwsA(isA()), + ); + }); + + test('parses URI with a bare ENS name', () { + expect( + EthUrlParser.parse('ethereum:vitalik.eth'), TransactionRequest( scheme: 'ethereum', - prefix: 'foo', - targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + targetAddress: 'vitalik.eth', ), ); }); - test('parses URI with an ENS name', () { + test('parses a dashed ENS name as the target, not a prefix', () { expect( EthUrlParser.parse( 'ethereum:foo-doge-to-the-moon.eth', ), TransactionRequest( scheme: 'ethereum', - prefix: 'foo', - targetAddress: 'doge-to-the-moon.eth', + targetAddress: 'foo-doge-to-the-moon.eth', + ), + ); + }); + + test('parses pay-prefixed ENS name with chain id', () { + expect( + EthUrlParser.parse('ethereum:pay-vitalik.eth@1'), + TransactionRequest( + scheme: 'ethereum', + prefix: 'pay', + targetAddress: 'vitalik.eth', + chainId: 1, ), ); }); @@ -70,23 +87,36 @@ void main() { ); }); - test('parses an ERC20 token transfer', () { + test('parses the EIP-681 ERC20 token transfer example', () { expect( EthUrlParser.parse( - 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD/transfer?address=0x12345&uint256=1', + 'ethereum:0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1', ), TransactionRequest( scheme: 'ethereum', - targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + targetAddress: '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', functionName: 'transfer', parameters: { - 'address': '0x12345', + 'address': '0x8e23ee67d1332ad560396262c48ffbb01f93d052', 'uint256': '1', }, ), ); }); + test('parses the EIP-681 native payment example', () { + expect( + EthUrlParser.parse( + 'ethereum:0xfb6916095ca1df60bB79Ce92cE3Ea74c37c5d359?value=2.014e18', + ), + TransactionRequest( + scheme: 'ethereum', + targetAddress: '0xfb6916095ca1df60bB79Ce92cE3Ea74c37c5d359', + parameters: {'value': '2014000000000000000'}, + ), + ); + }); + test('parses URI with value and gas parameters', () { expect( EthUrlParser.parse( @@ -113,14 +143,14 @@ void main() { ); }); - test('throws on short URI (less than 9 chars)', () { + test('throws FormatException on short URI (less than 9 chars)', () { expect( () => EthUrlParser.parse('eth:0x1'), - throwsA(isA()), + throwsA(isA()), ); }); - test('throws on invalid amount (NaN)', () { + test('throws FormatException on invalid amount (NaN)', () { expect( () => EthUrlParser.parse( 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=notanumber', @@ -128,6 +158,103 @@ void main() { throwsA(isA()), ); }); + + test('preserves wei above int64 exactly (10 ETH + 1 wei)', () { + final result = EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=10000000000000000001', + ); + expect(result.parameters['value'], '10000000000000000001'); + }); + + test('preserves 1 ETH + 1 wei exactly', () { + final result = EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=1000000000000000001', + ); + expect(result.parameters['value'], '1000000000000000001'); + }); + + test('accepts a leading + sign on value per the number grammar', () { + final result = EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=+1e18', + ); + expect(result.parameters['value'], '1000000000000000000'); + }); + + test('rejects negative value', () { + expect( + () => EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=-1e18', + ), + throwsA(isA()), + ); + }); + + test('rejects non-integer number (exponent smaller than decimals)', () { + expect( + () => EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=1.5e0', + ), + throwsA(isA()), + ); + }); + + test('rejects 40-char non-hex address (no ENS fallback for 0x)', () { + expect( + () => EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCZ', + ), + throwsA(isA()), + ); + expect( + () => EthUrlParser.parse( + 'ethereum:0x1234_EADBEEF5678ABCD1234DEADBEEF5678ABCD', + ), + throwsA(isA()), + ); + }); + + test('rejects hex address of the wrong length', () { + expect( + () => EthUrlParser.parse('ethereum:0x1234DEADBEEF'), + throwsA(isA()), + ); + }); + + test('rejects non-numeric chain id', () { + expect( + () => EthUrlParser.parse( + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD@abc', + ), + throwsA(isA()), + ); + }); + + test('rejects empty target', () { + expect( + () => EthUrlParser.parse('ethereum:'), + throwsA(isA()), + ); + expect( + () => EthUrlParser.parse('ethereum:pay-'), + throwsA(isA()), + ); + }); + + test('rejects a target that is neither an address nor an ENS name', () { + expect( + () => EthUrlParser.parse('ethereum:notanaddress'), + throwsA(isA()), + ); + }); + + test('validates the address parameter of a transfer', () { + expect( + () => EthUrlParser.parse( + 'ethereum:0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7/transfer?address=0x12345&uint256=1', + ), + throwsA(isA()), + ); + }); }); group('EthUrlParser.build', () { @@ -156,16 +283,16 @@ void main() { ); }); - test('builds URL with foo prefix', () { + test('rejects non-pay prefix (only pay- is defined by EIP-681)', () { expect( - EthUrlParser.build( + () => EthUrlParser.build( TransactionRequest( scheme: 'ethereum', prefix: 'foo', targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', ), ), - 'ethereum:foo-0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + throwsA(isA()), ); }); @@ -174,11 +301,10 @@ void main() { EthUrlParser.build( TransactionRequest( scheme: 'ethereum', - prefix: 'foo', targetAddress: 'doge-to-the-moon.eth', ), ), - 'ethereum:foo-doge-to-the-moon.eth', + 'ethereum:doge-to-the-moon.eth', ); }); @@ -200,15 +326,15 @@ void main() { EthUrlParser.build( TransactionRequest( scheme: 'ethereum', - targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + targetAddress: '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', functionName: 'transfer', parameters: { - 'address': '0x12345', + 'address': '0x8e23ee67d1332ad560396262c48ffbb01f93d052', 'uint256': '1', }, ), ), - 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD/transfer?address=0x12345&uint256=1', + 'ethereum:0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7/transfer?address=0x8e23ee67d1332ad560396262c48ffbb01f93d052&uint256=1', ); }); @@ -229,6 +355,58 @@ void main() { 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=2.014e18&gas=10&gasLimit=21000&gasPrice=50', ); }); + + test('emits plain decimal when compression would lose wei (1 ETH + 1 wei)', + () { + expect( + EthUrlParser.build( + TransactionRequest( + scheme: 'ethereum', + targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + parameters: {'value': '1000000000000000001'}, + ), + ), + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=1000000000000000001', + ); + }); + + test('preserves wei above int64 exactly (10 ETH + 1 wei)', () { + expect( + EthUrlParser.build( + TransactionRequest( + scheme: 'ethereum', + targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + parameters: {'value': '10000000000000000001'}, + ), + ), + 'ethereum:0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD?value=10000000000000000001', + ); + }); + + test('rejects negative value', () { + expect( + () => EthUrlParser.build( + TransactionRequest( + scheme: 'ethereum', + targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + parameters: {'value': '-1'}, + ), + ), + throwsA(isA()), + ); + }); + + test('rejects an invalid target address', () { + expect( + () => EthUrlParser.build( + TransactionRequest( + scheme: 'ethereum', + targetAddress: '0x12345', + ), + ), + throwsA(isA()), + ); + }); }); group('TransactionRequest', () { @@ -370,10 +548,17 @@ void main() { test('parse URL-encoded values', () { expect( - QueryString.parse('name=hello+world&path=%2Ffoo%2Fbar'), + QueryString.parse('name=hello%20world&path=%2Ffoo%2Fbar'), {'name': 'hello world', 'path': '/foo/bar'}, ); }); + + test('parse keeps a literal plus sign (RFC 3986, not form encoding)', () { + expect( + QueryString.parse('value=+1e18'), + {'value': '+1e18'}, + ); + }); }); group('Round-trip', () { @@ -398,10 +583,10 @@ void main() { test('parse(build(tx)) preserves functionName and parameters', () { final tx = TransactionRequest( - targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + targetAddress: '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', functionName: 'transfer', parameters: { - 'address': '0x12345', + 'address': '0x8e23ee67d1332ad560396262c48ffbb01f93d052', 'uint256': '1', }, ); @@ -410,5 +595,26 @@ void main() { expect(result.parameters['address'], tx.parameters['address']); expect(result.parameters['uint256'], tx.parameters['uint256']); }); + + test('parse(build(tx)) preserves every wei on adversarial amounts', () { + for (final wei in [ + '1000000000000000001', + '10000000000000000001', + '2014000000000000000', + ]) { + final tx = TransactionRequest( + targetAddress: '0x1234DEADBEEF5678ABCD1234DEADBEEF5678ABCD', + parameters: {'value': wei}, + ); + final result = EthUrlParser.parse(EthUrlParser.build(tx)); + expect(result.parameters['value'], wei); + } + }); + + test('build(parse(uri)) preserves the spec example URI', () { + const uri = + 'ethereum:0xfb6916095ca1df60bB79Ce92cE3Ea74c37c5d359?value=2.014e18'; + expect(EthUrlParser.build(EthUrlParser.parse(uri)), uri); + }); }); }