requestFaucetV3 function
Fetches a challenge, solves it and submits exactly one payout request.
Implementation
Future<FaucetV3Response> requestFaucetV3(
String endpoint,
String address, {
Dio? dio,
CancelToken? cancelToken,
Duration timeout = const Duration(minutes: 3),
Map<String, String> headers = const {},
}) async {
if (address.replaceFirst(RegExp(r'^0x', caseSensitive: false), '').isEmpty) {
throw ArgumentError.value(address, 'recipient');
}
final recipient = normalizeSuiAddress(address);
if (!isValidSuiAddress(recipient)) {
throw ArgumentError.value(address, 'recipient');
}
if (timeout <= Duration.zero) throw ArgumentError.value(timeout, 'timeout');
final http = dio ?? Dio();
final ownsClient = dio == null;
final token = CancelToken();
final started = Stopwatch()..start();
final timer = Timer(
timeout,
() => token.cancel(TimeoutException('Faucet request timed out', timeout)),
);
if (cancelToken?.isCancelled ?? false) token.cancel(cancelToken!.cancelError);
unawaited(cancelToken?.whenCancel.then(token.cancel) ?? Future<void>.value());
void checkCancelled() {
if (token.isCancelled) {
final reason = token.cancelError?.error;
if (reason is TimeoutException) throw reason;
throw token.cancelError!;
}
}
Future<Map<String, dynamic>> request(
String path, [
Map<String, dynamic>? body,
]) async {
checkCancelled();
final Response<dynamic> response;
try {
response = await http.request<dynamic>(
Uri.parse(endpoint).resolve(path).toString(),
data: body,
cancelToken: token,
options: Options(
method: body == null ? 'GET' : 'POST',
headers: headers,
contentType: 'application/json',
validateStatus: (_) => true,
),
);
} on DioException {
checkCancelled();
rethrow;
}
checkCancelled();
final data = response.data;
if ((response.statusCode ?? 500) >= 400) {
throw FaucetException(
data is Map && data['error'] is String
? data['error'] as String
: 'Faucet request failed',
status: response.statusCode ?? 500,
code: data is Map && data['code'] is String
? data['code'] as String
: null,
digest: data is Map && data['digest'] is String
? data['digest'] as String
: null,
);
}
if (data is! Map) {
throw const FormatException('Invalid faucet JSON response');
}
return Map<String, dynamic>.from(data);
}
try {
final random = Random.secure();
while (true) {
final challengeStart = started.elapsed;
final challenge = FaucetChallenge.fromJson(
await request('/v3/challenge?recipient=$recipient'),
);
if (challenge.recipient != recipient) {
throw const FormatException('Faucet challenge recipient mismatch');
}
final deadline =
challengeStart +
Duration(milliseconds: challenge.windowSeconds * 800);
var nonce =
(BigInt.from(random.nextInt(0x100000000)) << 32) |
BigInt.from(random.nextInt(0x100000000));
while (started.elapsed < deadline) {
checkCancelled();
final hash = await Future.any([
challenge.hashProof(nonce),
token.whenCancel.then<Uint8List>((_) {
checkCancelled();
throw StateError('Cancelled');
}),
]);
await Future<void>.delayed(Duration.zero);
checkCancelled();
if (started.elapsed >= deadline) break;
final first = hash
.take(8)
.fold(
BigInt.zero,
(BigInt value, byte) => (value << 8) | BigInt.from(byte),
);
if (first < challenge.threshold) {
final result = FaucetV3Response.fromJson(
await request('/v3/gas', {
'recipient': recipient,
'checkpointSeq': challenge.checkpointSeq,
'nonce': nonce.toString(),
'hashHex': hexEncode(hash),
}),
);
if (result.recipient != recipient) {
throw const FormatException('Faucet response recipient mismatch');
}
return result;
}
nonce = (nonce + BigInt.one) % _u64Limit;
}
}
} finally {
timer.cancel();
started.stop();
if (ownsClient) http.close();
}
}