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 );