feat: parsing addresses+unstoppable domains from twitter profiles (#2663)
* feat: parsing addresses+unstoppable domains from twitter profiles * fix: address resolution
malik1004x committed
Nov 21, 2025 at 18:08 UTC
46ba15f3f01b3f7e8f801a6abe3ba5eb188364a3
3 files changed
+78
-4
lib/entities/parse_address_from_domain.dart
+74
-3
@@ -193,6 +193,17 @@ class AddressResolver {
193
});
194
}
195
196
+ static String extractUnstoppableDomain(String raw) {
197
+ // sort by length to avoid matching shorter tld instead of longer
198
+ final domains = List<String>.from(unstoppableDomains)..sort((a, b) => b.length.compareTo(a.length));
199
+ for (final tld in domains) {
200
+ final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false);
201
+ final match = pattern.firstMatch(raw);
202
+ if (match != null) return match.group(0)!;
203
+ }
204
+ return '';
205
+ }
206
+
207
bool isEmailFormat(String address) {
208
final RegExp emailRegex = RegExp(
209
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
@@ -228,19 +239,78 @@ class AddressResolver {
239
name: text,
240
profileImageUrl: twitterUser.profileImageUrl,
241
profileName: twitterUser.name);
242
+ } else {
243
+ final domain = extractUnstoppableDomain(twitterUser.description);
244
+ if (domain.isNotEmpty) {
245
+ final parsedAddressFromDomain = await resolve(context, domain, currency);
246
+ if (parsedAddressFromDomain.addresses.isNotEmpty) {
247
+ return ParsedAddress(
248
+ addresses: parsedAddressFromDomain.addresses,
249
+ name: text,
250
+ profileImageUrl: twitterUser.profileImageUrl,
251
+ profileName: twitterUser.name,
252
+ parseFrom: ParseFrom.twitter,
253
+ );
254
+ }
255
+
256
+ }
257
+
258
+ }
259
+
260
+ final addressFromLocation = extractAddressByType(
261
+ raw: twitterUser.location,
262
+ type: CryptoCurrency.fromString(ticker,
263
+ walletCurrency: wallet.currency), requireSurroundingWhitespaces: false);
264
+ if (addressFromLocation != null && addressFromLocation.isNotEmpty) {
265
+ return ParsedAddress.fetchTwitterAddress(
266
+ address: addressFromLocation,
267
+ name: text,
268
+ profileImageUrl: twitterUser.profileImageUrl,
269
+ profileName: twitterUser.name);
270
+ } else {
271
+ final domain = extractUnstoppableDomain(twitterUser.location);
272
+ if (domain.isNotEmpty) {
273
+ final parsedAddressFromDomain = await resolve(context, domain, currency);
274
+ if (parsedAddressFromDomain.addresses.isNotEmpty) {
275
+ return ParsedAddress(
276
+ addresses: parsedAddressFromDomain.addresses,
277
+ name: text,
278
+ profileImageUrl: twitterUser.profileImageUrl,
279
+ profileName: twitterUser.name,
280
+ parseFrom: ParseFrom.twitter,
281
+ );
282
+ }
283
+
284
+ }
285
+
286
}
287
288
final pinnedTweet = twitterUser.pinnedTweet?.text;
289
if (pinnedTweet != null) {
290
final addressFromPinnedTweet = extractAddressByType(
291
raw: pinnedTweet,
237
- type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency));
292
+ type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency),
293
+ requireSurroundingWhitespaces: false);
294
if (addressFromPinnedTweet != null) {
295
return ParsedAddress.fetchTwitterAddress(
296
address: addressFromPinnedTweet,
297
name: text,
298
profileImageUrl: twitterUser.profileImageUrl,
299
profileName: twitterUser.name);
300
+ } else {
301
+ final domain = extractUnstoppableDomain(pinnedTweet);
302
+ if (domain.isNotEmpty) {
303
+ final parsedAddressFromDomain = await resolve(context, domain, currency);
304
+ if (parsedAddressFromDomain.addresses.isNotEmpty) {
305
+ return ParsedAddress(
306
+ addresses: parsedAddressFromDomain.addresses,
307
+ name: text,
308
+ profileImageUrl: twitterUser.profileImageUrl,
309
+ profileName: twitterUser.name,
310
+ parseFrom: ParseFrom.twitter,
311
+ );
312
+ }
313
+ }
314
}
315
}
316
}
@@ -258,7 +328,7 @@ class AddressResolver {
328
await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName);
329
330
if (mastodonUser != null) {
261
- String? addressFromBio = extractAddressByType(raw: mastodonUser.note, type: currency);
331
+ String? addressFromBio = extractAddressByType(raw: mastodonUser.note, type: currency, requireSurroundingWhitespaces: false);
332
333
if (addressFromBio != null && addressFromBio.isNotEmpty) {
334
return ParsedAddress.fetchMastodonAddress(
@@ -273,7 +343,8 @@ class AddressResolver {
343
if (pinnedPosts.isNotEmpty) {
344
final userPinnedPostsText = pinnedPosts.map((item) => item.content).join('\n');
345
String? addressFromPinnedPost =
276
- extractAddressByType(raw: userPinnedPostsText, type: currency);
346
+ extractAddressByType(raw: userPinnedPostsText, type: currency,
347
+ requireSurroundingWhitespaces: false);
348
349
if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) {
350
return ParsedAddress.fetchMastodonAddress(
lib/twitter/twitter_api.dart
+1
-1
@@ -12,7 +12,7 @@ class TwitterApi {
12
13
static Future<TwitterUser> lookupUserByName({required String userName}) async {
14
final queryParams = {
15
- 'user.fields': 'description,profile_image_url',
15
+ 'user.fields': 'description,profile_image_url,location',
16
'expansions': 'pinned_tweet_id',
17
'tweet.fields': 'note_tweet'
18
};
lib/twitter/twitter_user.dart
+3
@@ -4,6 +4,7 @@ class TwitterUser {
4
required this.username,
5
required this.name,
6
required this.description,
7
+ required this.location,
8
required this.profileImageUrl,
9
this.pinnedTweet});
10
@@ -11,6 +12,7 @@ class TwitterUser {
12
final String username;
13
final String name;
14
final String description;
15
+ final String location;
16
final String profileImageUrl;
17
final Tweet? pinnedTweet;
18
@@ -22,6 +24,7 @@ class TwitterUser {
24
username: json['data']['username'] as String? ?? '',
25
name: json['data']['name'] as String,
26
description: json['data']['description'] as String? ?? '',
27
+ location: json['data']['location'] as String? ?? '',
28
profileImageUrl: scaledProfileImageUrl,
29
pinnedTweet: pinnedTweet,
30
);