diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a82c68709..e35fc361d 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -10,8 +10,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Dart - uses: dart-lang/setup-dart@v1 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get - name: Build run: dart build cli diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 66ce6b2bd..633456417 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -22,18 +22,22 @@ jobs: include: - os: ubuntu-latest target: linux_x64 + architecture: x64 platform_name: linux-x64 content_type: application/gzip - os: macos-latest target: macos_arm64 + architecture: arm64 platform_name: macos-arm64 content_type: application/gzip - os: macos-latest target: macos_x64 + architecture: x64 platform_name: macos-x64 content_type: application/gzip - os: windows-latest target: windows_x64 + architecture: x64 platform_name: windows-x64 content_type: application/gzip defaults: @@ -44,23 +48,20 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Dart - if: matrix.target != 'macos_x64' - uses: dart-lang/setup-dart@v1 + - name: Install Rosetta + if: matrix.target == 'macos_x64' && runner.arch == 'ARM64' + run: softwareupdate --install-rosetta --agree-to-license - - name: Set up Dart (x86_64 SDK) - if: matrix.target == 'macos_x64' - run: | - curl -fsSL "https://storage.googleapis.com/dart-archive/channels/stable/release/latest/sdk/dartsdk-macos-x64-release.zip" -o dart-sdk.zip - unzip -q dart-sdk.zip - echo "$PWD/dart-sdk/bin" >> $GITHUB_PATH + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + architecture: ${{ matrix.architecture }} + cache: true - name: Install dependencies - run: dart pub get - - - name: Install Rosetta - if: matrix.target == 'macos_x64' - run: softwareupdate --install-rosetta --agree-to-license + run: flutter pub get - name: Build CLI executable (x64 via Rosetta) run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c579a0b8..6c6e8e1d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,18 +23,22 @@ jobs: include: - os: ubuntu-latest target: linux_x64 + architecture: x64 platform_name: linux-x64 content_type: application/gzip - os: macos-latest target: macos_arm64 + architecture: arm64 platform_name: macos-arm64 content_type: application/gzip - os: macos-latest target: macos_x64 + architecture: x64 platform_name: macos-x64 content_type: application/gzip - os: windows-latest target: windows_x64 + architecture: x64 platform_name: windows-x64 content_type: application/gzip defaults: @@ -45,23 +49,20 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Dart - if: matrix.target != 'macos_x64' - uses: dart-lang/setup-dart@v1 + - name: Install Rosetta + if: matrix.target == 'macos_x64' && runner.arch == 'ARM64' + run: softwareupdate --install-rosetta --agree-to-license - - name: Set up Dart (x86_64 SDK) - if: matrix.target == 'macos_x64' - run: | - curl -fsSL "https://storage.googleapis.com/dart-archive/channels/stable/release/latest/sdk/dartsdk-macos-x64-release.zip" -o dart-sdk.zip - unzip -q dart-sdk.zip - echo "$PWD/dart-sdk/bin" >> $GITHUB_PATH + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + architecture: ${{ matrix.architecture }} + cache: true - name: Install dependencies - run: dart pub get - - - name: Install Rosetta - if: matrix.target == 'macos_x64' - run: softwareupdate --install-rosetta --agree-to-license + run: flutter pub get - name: Build CLI executable (x64 via Rosetta) run: | diff --git a/packages/ndk/example/nwc/connect_get_info.dart b/packages/ndk/example/nwc/connect_get_info.dart index e459b25c0..d42ce42a0 100644 --- a/packages/ndk/example/nwc/connect_get_info.dart +++ b/packages/ndk/example/nwc/connect_get_info.dart @@ -14,6 +14,9 @@ void main() async { final connection = await ndk.nwc.connect(nwcUri, doGetInfoMethod: true); print("Connected, permissions: ${connection.permissions}"); + print( + "Supported extensions: ${connection.supportedExtensions.map((extension) => '${extension.identifier} (${extension.name})').join(', ')}", + ); if (connection.info != null) { print("alias: ${connection.info!.alias}"); diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_extension.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_extension.dart new file mode 100644 index 000000000..9340d5eb8 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_extension.dart @@ -0,0 +1,47 @@ +/// Optional Nostr Wallet Connect extension specifications. +/// +/// The identifiers correspond to specification filenames in +/// https://github.com/nostr-wallet-connect/nwc. +enum NwcExtension { + notifications('02'), + holdInvoices('03'), + keysend('04'), + transactionHistory('05'), + metadata('06'), + deepLinks('07'), + bip321('321'); + + /// Identifier advertised by NWC wallet services. + final String identifier; + + const NwcExtension(this.identifier); + + /// Returns the extension matching [identifier], or `null` when it is not + /// known by this version of NDK. + static NwcExtension? fromIdentifier(String identifier) { + final normalizedIdentifier = identifier.trim(); + for (final extension in NwcExtension.values) { + if (extension.identifier == normalizedIdentifier) { + return extension; + } + } + return null; + } + + /// Parses identifiers from NIP-47 fields. + /// + /// Each input may be a single identifier, as used by `get_info`, or a + /// space-separated list, as used by the kind 13194 info event. + static Set fromIdentifiers(Iterable identifiers) { + final extensions = {}; + for (final value in identifiers) { + for (final identifier in value.split(RegExp(r'\s+'))) { + final extension = fromIdentifier(identifier); + if (extension != null) { + extensions.add(extension); + } + } + } + return extensions; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index a73d6eba1..b961f135f 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -117,6 +117,7 @@ class Nwc { if (encryptions.isNotEmpty) { connection.supportedEncryptions = encryptions.first.split(" "); } + connection.addSupportedExtensions(event.getTags('extensions')); await _subscribeToNotificationsAndResponses(connection); @@ -124,9 +125,7 @@ class Nwc { (ignoreCapabilitiesCheck || connection.permissions.contains(NwcMethod.GET_INFO.name))) { try { - await getInfo(connection, timeout: timeout).then((info) { - connection.info = info; - }); + await getInfo(connection, timeout: timeout); } catch (e) { onError?.call("timeout get_info"); } @@ -416,11 +415,14 @@ class Nwc { NwcConnection connection, { Duration? timeout, }) async { - return _executeRequest( + final info = await _executeRequest( connection, GetInfoRequest(), timeout: timeout, ); + connection.info = info; + connection.supportedExtensions.addAll(info.extensions); + return info; } /// Does a `get_balance` request diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart index 8bb6a6b49..044afd473 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart @@ -60,6 +60,20 @@ class NwcConnection { List supportedVersions = ["0.0"]; List supportedEncryptions = ["nip04"]; + /// Optional NWC extension specifications advertised by the wallet service. + Set supportedExtensions = {}; + + /// Adds extension identifiers advertised by an info event or `get_info`. + /// Unknown identifiers are ignored for forward compatibility. + void addSupportedExtensions(Iterable identifiers) { + supportedExtensions.addAll(NwcExtension.fromIdentifiers(identifiers)); + } + + /// Whether the wallet service advertises support for [extension]. + bool supportsExtension(NwcExtension extension) { + return supportedExtensions.contains(extension); + } + Set permissions = {}; final LocalEventSignerFactory eventSignerFactory; diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart index 840d41fd9..77113eea6 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/get_info_response.dart @@ -1,6 +1,7 @@ // ignore_for_file: camel_case_types import 'package:ndk/domain_layer/usecases/nwc/consts/bitcoin_network.dart'; +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_extension.dart'; import 'nwc_response.dart'; @@ -15,6 +16,9 @@ class GetInfoResponse extends NwcResponse { final List methods; final List notifications; + /// Optional NWC extensions supported by this connection. + final Set extensions; + GetInfoResponse({ required super.resultType, required this.alias, @@ -25,8 +29,14 @@ class GetInfoResponse extends NwcResponse { required this.blockHash, required this.methods, required this.notifications, + this.extensions = const {}, }); + /// Whether the wallet service advertises support for [extension]. + bool supportsExtension(NwcExtension extension) { + return extensions.contains(extension); + } + factory GetInfoResponse.deserialize(Map input) { if (!input.containsKey('result')) { throw Exception('Invalid input'); @@ -35,6 +45,7 @@ class GetInfoResponse extends NwcResponse { Map result = input['result'] as Map; final methodsList = (result["methods"] as List?) ?? const []; final notificationsList = (result["notifications"] as List?) ?? const []; + final extensionsList = (result["extensions"] as List?) ?? const []; List methods = methodsList.map((method) => method.toString()).toList(); @@ -53,6 +64,9 @@ class GetInfoResponse extends NwcResponse { blockHash: result['block_hash'] as String?, methods: methods, notifications: notifications, + extensions: NwcExtension.fromIdentifiers( + extensionsList.map((extension) => extension.toString()), + ), ); } } diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 99c1bf9d3..2eeedce74 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -41,6 +41,7 @@ export 'domain_layer/usecases/nwc/responses/lookup_invoice_response.dart'; export 'domain_layer/usecases/nwc/nwc_connection.dart'; export 'domain_layer/usecases/nwc/nostr_wallet_connect_uri.dart'; export 'domain_layer/usecases/nwc/consts/nwc_method.dart'; +export 'domain_layer/usecases/nwc/consts/nwc_extension.dart'; export 'domain_layer/usecases/nwc/consts/budget_renewal_period.dart'; export 'domain_layer/entities/blossom_blobs.dart'; export 'domain_layer/entities/blossom_strategies.dart'; diff --git a/packages/ndk/test/usecases/nwc/nwc_extension_test.dart b/packages/ndk/test/usecases/nwc/nwc_extension_test.dart new file mode 100644 index 000000000..0e99ba70b --- /dev/null +++ b/packages/ndk/test/usecases/nwc/nwc_extension_test.dart @@ -0,0 +1,84 @@ +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() { + group('NwcExtension', () { + test('maps the registered NWC extension identifiers', () { + expect(NwcExtension.fromIdentifier('02'), NwcExtension.notifications); + expect(NwcExtension.fromIdentifier('03'), NwcExtension.holdInvoices); + expect(NwcExtension.fromIdentifier('04'), NwcExtension.keysend); + expect( + NwcExtension.fromIdentifier('05'), + NwcExtension.transactionHistory, + ); + expect( + NwcExtension.fromIdentifier('06'), + NwcExtension.metadata, + ); + expect(NwcExtension.fromIdentifier('07'), NwcExtension.deepLinks); + expect( + NwcExtension.fromIdentifier('321'), + NwcExtension.bip321, + ); + }); + + test('parses space-separated values and ignores unknown extensions', () { + expect( + NwcExtension.fromIdentifiers(['02 03\t321', '999']), + { + NwcExtension.notifications, + NwcExtension.holdInvoices, + NwcExtension.bip321, + }, + ); + expect(NwcExtension.fromIdentifier('999'), isNull); + }); + }); + + group('GetInfoResponse extensions', () { + test('deserializes and checks supported extensions', () { + final response = GetInfoResponse.deserialize({ + 'result_type': 'get_info', + 'result': { + 'alias': 'wallet', + 'network': 'mainnet', + 'methods': ['pay_invoice'], + 'extensions': ['02', '05', 'unknown'], + }, + }); + + expect( + response.extensions, + {NwcExtension.notifications, NwcExtension.transactionHistory}, + ); + expect(response.supportsExtension(NwcExtension.notifications), isTrue); + expect(response.supportsExtension(NwcExtension.holdInvoices), isFalse); + }); + + test('defaults to no extensions when the optional field is absent', () { + final response = GetInfoResponse.deserialize({ + 'result_type': 'get_info', + 'result': {'alias': 'wallet', 'network': 'mainnet'}, + }); + + expect(response.extensions, isEmpty); + }); + }); + + test('NwcConnection can add and check advertised extensions', () { + final connection = NwcConnection( + NostrWalletConnectUri( + walletPubkey: 'wallet-pubkey', + relays: ['wss://relay.example.com'], + secret: 'secret', + ), + eventSignerFactory: const Bip340EventSignerFactory(), + ); + + connection.addSupportedExtensions(['02 04']); + + expect(connection.supportsExtension(NwcExtension.notifications), isTrue); + expect(connection.supportsExtension(NwcExtension.keysend), isTrue); + expect(connection.supportsExtension(NwcExtension.holdInvoices), isFalse); + }); +}