Skip to content

Commit e161b16

Browse files
committed
sponsored gas fees for NFT
1 parent cff5296 commit e161b16

14 files changed

Lines changed: 884 additions & 49 deletions

lib/core/models/billing/supported_chain.dart

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ class SupportedChain extends Equatable {
99
final String? explorerUrl;
1010
final int decimals;
1111
final String? nftContractAddress;
12+
/// Whether this chain has zero/free gas (e.g. Skale sFUEL).
13+
/// When true, gasless meta-tx relay works without a gas deposit.
14+
final bool freeGas;
1215

1316
const SupportedChain({
1417
required this.chainId,
@@ -19,6 +22,7 @@ class SupportedChain extends Equatable {
1922
this.explorerUrl,
2023
this.decimals = 18,
2124
this.nftContractAddress,
25+
this.freeGas = false,
2226
});
2327

2428
/// Fixed vault address for all chains
@@ -33,7 +37,7 @@ class SupportedChain extends Equatable {
3337
rpcUrl: 'https://mainnet.base.org',
3438
explorerUrl: 'https://basescan.org',
3539
decimals: 18,
36-
nftContractAddress: '0xc818477eBaf05B8239fd7f9AaeE25fE41981fd33',
40+
nftContractAddress: '0x9a219BA802227a434dfCF1E993B71f6bC63e877f',
3741
);
3842

3943
/// Skale Europa chain configuration
@@ -45,7 +49,8 @@ class SupportedChain extends Equatable {
4549
rpcUrl: 'https://mainnet.skalenodes.com/v1/elated-tan-skat',
4650
explorerUrl: 'https://elated-tan-skat.explorer.mainnet.skalenodes.com',
4751
decimals: 18,
48-
nftContractAddress: '0x0B5b76F709BDc17c9f29e1D752439345BDC67b0c',
52+
nftContractAddress: '0x04d43CF942B754Ad92A0b0baf3D2f36D20c9721F',
53+
freeGas: true,
4954
);
5055

5156
/// All supported chains
@@ -69,6 +74,7 @@ class SupportedChain extends Equatable {
6974
explorerUrl: json['explorerUrl'] as String? ?? json['explorer_url'] as String?,
7075
decimals: json['decimals'] as int? ?? 18,
7176
nftContractAddress: json['nftContractAddress'] as String? ?? json['nft_contract_address'] as String?,
77+
freeGas: json['freeGas'] as bool? ?? json['free_gas'] as bool? ?? false,
7278
);
7379
}
7480

@@ -82,6 +88,7 @@ class SupportedChain extends Equatable {
8288
'explorerUrl': explorerUrl,
8389
'decimals': decimals,
8490
'nftContractAddress': nftContractAddress,
91+
'freeGas': freeGas,
8592
};
8693
}
8794

@@ -96,9 +103,15 @@ class SupportedChain extends Equatable {
96103
explorerUrl: explorerUrl,
97104
decimals: decimals,
98105
nftContractAddress: nftContractAddress,
106+
freeGas: freeGas,
99107
);
100108
}
101109

110+
/// Whether this chain supports gasless meta-tx relay.
111+
/// True if the chain has free gas (no deposit needed) or has an NFT contract deployed.
112+
bool get supportsGaslessRelay => nftContractAddress != null &&
113+
nftContractAddress != '0x0000000000000000000000000000000000000000';
114+
102115
/// Get explorer URL for a transaction
103116
String? getTxExplorerUrl(String txHash) {
104117
if (explorerUrl == null) return null;
@@ -111,6 +124,21 @@ class SupportedChain extends Equatable {
111124
return '$explorerUrl/address/$address';
112125
}
113126

127+
/// OpenSea chain slug, or null if not supported.
128+
String? get openSeaSlug {
129+
switch (chainId) {
130+
case 8453: return 'base';
131+
default: return null;
132+
}
133+
}
134+
135+
/// OpenSea URL for an NFT, or null if chain not supported.
136+
String? getOpenSeaUrl(String contractAddress, int tokenId) {
137+
final slug = openSeaSlug;
138+
if (slug == null) return null;
139+
return 'https://opensea.io/assets/$slug/$contractAddress/$tokenId';
140+
}
141+
114142
@override
115143
List<Object?> get props => [
116144
chainId,
@@ -121,5 +149,6 @@ class SupportedChain extends Equatable {
121149
explorerUrl,
122150
decimals,
123151
nftContractAddress,
152+
freeGas,
124153
];
125154
}

lib/core/models/nft_token.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ class ReceivedNft extends HiveObject {
306306
@HiveField(12)
307307
String? transferTxHash;
308308

309+
/// The claim link hash — needed for gasless burn/transfer via meta-tx relay.
310+
@HiveField(13)
311+
String? claimLinkHash;
312+
309313
ReceivedNft({
310314
required this.id,
311315
required this.tokenId,
@@ -320,6 +324,7 @@ class ReceivedNft extends HiveObject {
320324
this.status = ReceivedNftStatus.held,
321325
this.burnTxHash,
322326
this.transferTxHash,
327+
this.claimLinkHash,
323328
});
324329

325330
Map<String, dynamic> toJson() {
@@ -337,6 +342,7 @@ class ReceivedNft extends HiveObject {
337342
'status': status.index,
338343
'burnTxHash': burnTxHash,
339344
'transferTxHash': transferTxHash,
345+
'claimLinkHash': claimLinkHash,
340346
};
341347
}
342348

@@ -355,6 +361,7 @@ class ReceivedNft extends HiveObject {
355361
status: ReceivedNftStatus.values[json['status'] as int? ?? 0],
356362
burnTxHash: json['burnTxHash'] as String?,
357363
transferTxHash: json['transferTxHash'] as String?,
364+
claimLinkHash: json['claimLinkHash'] as String?,
358365
);
359366
}
360367

lib/core/models/nft_token.g.dart

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import 'dart:convert';
2+
3+
import 'package:flutter/foundation.dart';
4+
import 'package:http/http.dart' as http;
5+
6+
import 'package:fula_files/core/models/billing/supported_chain.dart';
7+
import 'package:fula_files/core/services/nft_contract_service.dart';
8+
9+
/// Relay client for gasless meta-tx claims on Base.
10+
///
11+
/// The creator deposits ETH when creating a claim offer. The relay submits
12+
/// the meta-tx on behalf of the claimer and is reimbursed from the deposit.
13+
class MetaTxRelayService {
14+
MetaTxRelayService._();
15+
static final MetaTxRelayService instance = MetaTxRelayService._();
16+
17+
static const String _relayUrl = 'https://cloud.fx.land/api/v1/nft/relay';
18+
static const Duration _timeout = Duration(seconds: 30);
19+
20+
/// Check if a claim link has a gas deposit (indicating gasless is available).
21+
Future<BigInt> getGasDeposit({
22+
required int chainId,
23+
required String linkHash,
24+
}) async {
25+
final contract = NftContractService.instance;
26+
// claimGasDeposits(bytes32) — public mapping getter
27+
// selector: keccak256("claimGasDeposits(bytes32)")[:4]
28+
const selector = '6e3e2fa3';
29+
final hashHex = linkHash.replaceFirst('0x', '').padLeft(64, '0');
30+
final data = '0x$selector$hashHex';
31+
32+
final chain = SupportedChain.byChainId(chainId);
33+
if (chain?.nftContractAddress == null) return BigInt.zero;
34+
35+
try {
36+
final result = await contract.ethCall(
37+
chainId: chainId,
38+
contractAddress: chain!.nftContractAddress!,
39+
data: data,
40+
);
41+
final hex = result.replaceFirst('0x', '');
42+
if (hex.isEmpty || hex.length < 64) return BigInt.zero;
43+
return BigInt.parse(hex.substring(0, 64), radix: 16);
44+
} catch (e) {
45+
debugPrint('MetaTxRelayService: getGasDeposit error: $e');
46+
return BigInt.zero;
47+
}
48+
}
49+
50+
/// Get the meta-tx nonce for a signer address from the contract.
51+
Future<int> getMetaNonce({
52+
required int chainId,
53+
required String address,
54+
}) async {
55+
final contract = NftContractService.instance;
56+
// metaNonces(address) — public mapping getter
57+
const selector = 'a3c573eb';
58+
final addrHex = address.toLowerCase().replaceFirst('0x', '').padLeft(64, '0');
59+
final data = '0x$selector$addrHex';
60+
61+
final chain = SupportedChain.byChainId(chainId);
62+
if (chain?.nftContractAddress == null) return 0;
63+
64+
try {
65+
final result = await contract.ethCall(
66+
chainId: chainId,
67+
contractAddress: chain!.nftContractAddress!,
68+
data: data,
69+
);
70+
return contract.decodeUint256(result) ?? 0;
71+
} catch (e) {
72+
debugPrint('MetaTxRelayService: getMetaNonce error: $e');
73+
return 0;
74+
}
75+
}
76+
77+
/// Submit a meta-transaction via the relay.
78+
/// For claim actions: pass [secret] (the raw preimage). The relay passes it to claimNFTMeta.
79+
/// For burn/transferBack: pass [claimKey] (the public identifier).
80+
/// Returns the transaction hash.
81+
Future<String> relay({
82+
required String action,
83+
required int chainId,
84+
String? secret,
85+
String? claimKey,
86+
required String signer,
87+
required int deadline,
88+
required int nonce,
89+
required String signature,
90+
int? tokenId,
91+
int? amount,
92+
}) async {
93+
final body = <String, dynamic>{
94+
'action': action,
95+
'chainId': chainId,
96+
'signer': signer,
97+
'deadline': deadline,
98+
'nonce': nonce,
99+
'signature': signature,
100+
};
101+
// For claim: send secret (relay needs it for claimNFTMeta) and claimKey (for gas deposit lookup)
102+
if (secret != null) body['secret'] = secret;
103+
if (claimKey != null) body['claimKey'] = claimKey;
104+
if (tokenId != null) body['tokenId'] = tokenId;
105+
if (amount != null) body['amount'] = amount;
106+
107+
final response = await http.post(
108+
Uri.parse(_relayUrl),
109+
headers: {'Content-Type': 'application/json'},
110+
body: jsonEncode(body),
111+
).timeout(_timeout);
112+
113+
final result = jsonDecode(response.body) as Map<String, dynamic>;
114+
115+
if (response.statusCode != 200 || result['success'] != true) {
116+
final error = result['error'] ?? 'Relay failed (${response.statusCode})';
117+
throw Exception(error);
118+
}
119+
120+
return result['txHash'] as String;
121+
}
122+
}

0 commit comments

Comments
 (0)