From 5189d264da602cebcf6058e8cf267ddfd3b7c0f7 Mon Sep 17 00:00:00 2001 From: Fixella Date: Thu, 22 Oct 2026 08:48:55 +0100 Subject: [PATCH] fix: Expand Specification Vectors for Edge Cases and Protocol Bou (#338) --- .github/workflows/ci-dart.yml | 10 +- .github/workflows/ci-go.yml | 11 +- .github/workflows/ci-ts.yml | 10 +- packages/core-dart/test/spec_runner_test.dart | 7 ++ packages/core-dart/test/uri_test.dart | 100 +++++++++++++++ packages/core-go/address/parse_test.go | 115 ++++++++++++++++++ 6 files changed, 229 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-dart.yml b/.github/workflows/ci-dart.yml index 96522951..e079f230 100644 --- a/.github/workflows/ci-dart.yml +++ b/.github/workflows/ci-dart.yml @@ -5,14 +5,6 @@ on: paths: - "packages/core-dart/**" - "spec/**" - # `verify-parity` owns this file's CI surface; skip here to avoid - # duplicate runs on vectors-only PRs. - paths-ignore: - - "spec/vectors.json" - # Note: paths + paths-ignore combine as an AND across all changed files. - # If a PR changes spec/vectors.json *together with* packages/core-dart/**, - # this workflow is skipped; verify-parity.yml still runs the identical - # commands, so functional coverage is preserved under a different check. jobs: test: @@ -26,6 +18,8 @@ jobs: - run: npm install ajv - name: Spec validator run: node spec/validate.js + - name: Spec vectors (edge cases) + run: node spec/run-vectors.js - id: setup-chrome uses: browser-actions/setup-chrome@v1 - run: cd packages/core-dart && dart pub get diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 01dc8901..71c19007 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -5,14 +5,6 @@ on: paths: - "packages/core-go/**" - "spec/**" - # `verify-parity` owns this file's CI surface; skip here to avoid - # duplicate runs on vectors-only PRs. - paths-ignore: - - "spec/vectors.json" - # Note: paths + paths-ignore combine as an AND across all changed files. - # If a PR changes spec/vectors.json *together with* packages/core-go/**, - # this workflow is skipped; verify-parity.yml still runs the identical - # commands, so functional coverage is preserved under a different check. jobs: test: @@ -23,3 +15,6 @@ jobs: with: go-version: "1.21" - run: cd packages/core-go && go test ./... + - run: cd packages/core-go && go test ./... -run TestSpecVectors -v + env: + SPEC_VECTORS: spec/vectors.json diff --git a/.github/workflows/ci-ts.yml b/.github/workflows/ci-ts.yml index 8c40e329..cdd2c662 100644 --- a/.github/workflows/ci-ts.yml +++ b/.github/workflows/ci-ts.yml @@ -5,14 +5,6 @@ on: paths: - "packages/core-ts/**" - "spec/**" - # `verify-parity` owns this file's CI surface; skip here to avoid - # duplicate runs on vectors-only PRs. - paths-ignore: - - "spec/vectors.json" - # Note: paths + paths-ignore combine as an AND across all changed files. - # If a PR changes spec/vectors.json *together with* packages/core-ts/**, - # this workflow is skipped; verify-parity.yml still runs the identical - # commands, so functional coverage is preserved under a different check. jobs: test: @@ -28,3 +20,5 @@ jobs: cache: "pnpm" - run: pnpm install - run: pnpm --filter stellar-address-kit test + - run: pnpm --filter stellar-address-kit test -- spec/vectors.json + name: Run expanded spec vectors (edge cases, Unicode, boundaries) diff --git a/packages/core-dart/test/spec_runner_test.dart b/packages/core-dart/test/spec_runner_test.dart index 6448010f..138b2fbd 100644 --- a/packages/core-dart/test/spec_runner_test.dart +++ b/packages/core-dart/test/spec_runner_test.dart @@ -1,12 +1,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:test/test.dart'; +import 'package:test/test.dart'; import 'package:stellar_address_kit/stellar_address_kit.dart'; const legacyVectorG = "GA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1"; const legacyVectorMPrefix = "MA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1"; const legacyVectorCPrefix = "CA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1"; +const validG = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; const validG = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; const validC = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; @@ -56,6 +58,7 @@ void main() { final BigInt id = BigInt.parse(input['id'].toString()); final String result = MuxedAddress.encode(baseG: baseG, id: id); expect(result, expected['mAddress']); + expect(result, expected['mAddress']); break; case 'muxed_decode': @@ -74,6 +77,7 @@ void main() { case 'detect': final kind = detect(input['address'].toString()); if (expected.containsKey('kind')) { + expect(kind?.toString().split('.').last.toUpperCase(), expect(kind?.toString().split('.').last.toUpperCase(), expected['kind']); } else { @@ -99,6 +103,7 @@ void main() { expect(result.destinationBaseAccount, normalizeExpectedBaseAccount(expected['destinationBaseAccount'])); + normalizeExpectedBaseAccount(expected['destinationBaseAccount'])); if (expected['routingId'] != null) { expect(result.id, BigInt.parse(expected['routingId'].toString())); @@ -111,6 +116,7 @@ void main() { if (expected.containsKey('warnings')) { final List expectedWarnings = expected['warnings'] as List; + expected['warnings'] as List; expect(result.warnings.length, expectedWarnings.length); for (var i = 0; i < expectedWarnings.length; i++) { final eW = expectedWarnings[i] as Map; @@ -121,6 +127,7 @@ void main() { if (expected.containsKey('destinationError')) { final eE = expected['destinationError'] as Map; expect(result.destinationError?.code, eE['code']); + expect(result.destinationError?.code, eE['code']); } } on ExtractRoutingException { if (destination.startsWith('C')) { diff --git a/packages/core-dart/test/uri_test.dart b/packages/core-dart/test/uri_test.dart index 9a3b8a9c..85b805f3 100644 --- a/packages/core-dart/test/uri_test.dart +++ b/packages/core-dart/test/uri_test.dart @@ -165,5 +165,105 @@ void main() { } }); }); + + group('protocol boundaries and adversarial inputs', () { + const validG = 'GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO'; + + test('handles uint64 boundary memo values', () { + const boundaries = [ + '0', + '1', + '18446744073709551615', + '18446744073709551616', + ]; + for (final value in boundaries) { + final uri = + 'web+stellar:pay?destination=$validG&memo=$value&memo_type=MEMO_ID'; + final result = extractRoutingFromURI(uri); + expect(result.success, isTrue, reason: 'memo=$value'); + if (result is ExtractRoutingFromUriSuccess) { + expect(result.rawParams.memo, value); + expect(result.rawParams.memoType, 'MEMO_ID'); + } + } + }); + + test('accepts secret seed, pre-auth, and hash signer destinations', () { + const signers = [ + 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'XAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ]; + for (final signer in signers) { + final uri = 'web+stellar:pay?destination=$signer'; + final result = extractRoutingFromURI(uri); + expect(result.success, isFalse, reason: 'signer=$signer'); + if (result is ExtractRoutingFromUriFailure) { + expect(result.code, Sep7UriErrorCode.invalidDestination); + } + } + }); + + test('preserves multi-byte UTF-8 memo text', () { + const memo = '\u00e9\u00e8\u00ea \u4f60\u597d \ud83d\ude00'; + final encoded = Uri.encodeComponent(memo); + final uri = + 'web+stellar:pay?destination=$validG&memo=$encoded&memo_type=MEMO_TEXT'; + final result = extractRoutingFromURI(uri); + expect(result.success, isTrue); + if (result is ExtractRoutingFromUriSuccess) { + expect(result.rawParams.memo, memo); + expect(result.rawParams.memoType, 'MEMO_TEXT'); + } + }); + + test('rejects zero-width and bidi override characters in destination', () { + const adversarial = [ + '\u200b', + '\u200c', + '\u200d', + '\u202a', + '\u202b', + '\u202c', + '\u202d', + '\u202e', + '\u2066', + '\u2067', + '\u2068', + '\u2069', + '\ufeff', + ]; + for (final hidden in adversarial) { + final uri = + 'web+stellar:pay?destination=${Uri.encodeComponent('$validG$hidden')}'; + final result = extractRoutingFromURI(uri); + expect(result.success, isFalse, reason: 'hidden=U+${hidden.codeUnitAt(0).toRadixString(16)}'); + if (result is ExtractRoutingFromUriFailure) { + expect(result.code, Sep7UriErrorCode.invalidDestination); + } + } + }); + + test('rejects malformed SEP-0007 URIs', () { + const malformed = [ + 'web+stellar:', + 'web+stellar:?', + 'web+stellar:pay?', + 'web+stellar:pay?destination', + 'web+stellar:pay?destination=', + 'web+stellar:pay?=value', + 'web+stellar:pay?destination=$validG&memo_type=INVALID', + 'web+stellar:pay?destination=$validG&memo_type=MEMO_ID&memo=notanumber', + 'web+stellar:pay?destination=$validG&amount=notanumber', + 'web+stellar:pay?destination=$validG&asset_code=USDC', + 'web+stellar:pay?destination=$validG&asset_issuer=GAP5LETOV6YIE62YAM56STDANPRDO7ZFDBGSNHJQIYGGKSMOZAHOOS2S', + 'web+stellar:pay?destination=$validG&callback=notaurl', + ]; + for (final uri in malformed) { + final result = extractRoutingFromURI(uri); + expect(result.success, isFalse, reason: 'uri=$uri'); + } + }); + }); }); } diff --git a/packages/core-go/address/parse_test.go b/packages/core-go/address/parse_test.go index b206db93..8beade63 100644 --- a/packages/core-go/address/parse_test.go +++ b/packages/core-go/address/parse_test.go @@ -1,5 +1,7 @@ package address +import "strings" + import "testing" func TestParseMuxedAddress(t *testing.T) { @@ -83,3 +85,116 @@ func TestParseMuxedAddress(t *testing.T) { } } +func TestParseMuxedAddressEdgeCases(t *testing.T) { + const baseG = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + + tests := []struct { + name string + mAddress string + wantBaseG string + wantMuxed uint64 + shouldFail bool + }{ + { + name: "decode id=0 boundary case", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672", + wantBaseG: baseG, + wantMuxed: 0, + }, + { + name: "decode id=1 small positive case", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAHOO2", + wantBaseG: baseG, + wantMuxed: 1, + }, + { + name: "decode id=2^64-1 max uint64", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAD7777777777774OFW", + wantBaseG: baseG, + wantMuxed: 18446744073709551615, + }, + { + name: "reject id=2^64 overflow", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAEAAAAAAAAAAAB777", + shouldFail: true, + }, + { + name: "reject truncated M-address", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAC", + shouldFail: true, + }, + { + name: "reject M-address with invalid checksum", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAHOO3", + shouldFail: true, + }, + { + name: "reject M-address with lowercase payload", + mAddress: "maycuyt553c5lhve2xpw5gmejt4bxgm7ahmjwlapzp53kjo7eiqacaaaaaaaaaaaad672", + shouldFail: true, + }, + { + name: "reject M-address with zero-width space", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672\u200b", + shouldFail: true, + }, + { + name: "reject M-address with bidi override", + mAddress: "\u202eMAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672", + shouldFail: true, + }, + { + name: "reject M-address with hidden unicode", + mAddress: "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672\ufeff", + shouldFail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := Parse(tt.mAddress) + + if tt.shouldFail { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if addr.Kind != KindM { + t.Fatalf("expected KindM, got %v", addr.Kind) + } + + if addr.BaseG != tt.wantBaseG { + t.Errorf("BaseG = %s, want %s", addr.BaseG, tt.wantBaseG) + } + + if addr.MuxedID != tt.wantMuxed { + t.Errorf("MuxedID = %d, want %d", addr.MuxedID, tt.wantMuxed) + } + }) + } +} + +func TestParseRejectsUnicodeInAddress(t *testing.T) { + const baseG = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + + bad := []string{ + baseG + "\u200b", + baseG + "\u202e", + baseG + "\ufeff", + "\u200b" + baseG, + strings.Replace(baseG, "G", "\u200bG", 1), + } + + for _, s := range bad { + if _, err := Parse(s); err == nil { + t.Errorf("expected error for %q, got none", s) + } + } +} +