Skip to content

4 · Fund Alice

Alice needs coins before she can pay. An SPV wallet counts only coins it can prove, so you won’t just send to her address and wait. You’ll:

  1. Get a receive address for Alice.
  2. Send coins from the faucet through Arcade.
  3. Mine a block, once a miner holds the transaction.
  4. Wait for Alice’s headers to reach that block.
  5. Build a BEEF from Arcade’s merkle proof and import it.

Add these to lib/localnet.dart. They drive the localnet scripts and Arcade’s API. They’re tutorial code, not libspiffy API.

lib/localnet.dart (add)
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
/// Sends [satoshis] from the localnet faucet to [address] through Arcade,
/// so that Arcade follows the payment to its merkle proof.
Future<({String txid, String rawHex})> faucetSend(
String address, int satoshis) async {
final result = await Process.run(
'./scripts/faucet.sh',
['send', address, '$satoshis', '--arcade'],
workingDirectory: localnetDir,
);
final out = '${result.stdout}';
final txid =
RegExp(r'^txid\s+([0-9a-f]{64})$', multiLine: true).firstMatch(out);
final hex = RegExp(r'^hex\s+([0-9a-f]+)$', multiLine: true).firstMatch(out);
if (result.exitCode != 0 || txid == null || hex == null) {
throw StateError('faucet send failed: $out ${result.stderr}');
}
return (txid: txid.group(1)!, rawHex: hex.group(1)!);
}
/// Mines one block on localnet.
Future<void> mineBlock() async {
final result = await Process.run('./scripts/mine.sh', ['1'],
workingDirectory: localnetDir);
if (result.exitCode != 0) {
throw StateError('mine.sh failed: ${result.stdout} ${result.stderr}');
}
}
/// Arcade's view of [txid]: its `txStatus`, and `merklePath` and
/// `blockHeight` once it is mined.
Future<Map<String, dynamic>> arcadeStatus(String txid) async {
final response = await http.get(Uri.parse('$arcadeUrl/tx/$txid'));
if (response.statusCode != 200) {
throw StateError('Arcade has no record of $txid: ${response.body}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
/// Waits until Arcade reports [txid] in one of [statuses].
Future<Map<String, dynamic>> waitForArcade(String txid, Set<String> statuses,
{Duration timeout = const Duration(seconds: 60)}) async {
final deadline = DateTime.now().add(timeout);
while (true) {
final status = await arcadeStatus(txid);
if (statuses.contains(status['txStatus'])) return status;
if (DateTime.now().isAfter(deadline)) {
throw StateError('$txid is ${status['txStatus']}, not one of $statuses');
}
await Future<void>.delayed(const Duration(milliseconds: 500));
}
}
/// A mined transaction as a BEEF: the raw transaction plus the merkle path
/// (a BUMP) that Arcade built for it.
Uint8List minedBeef(String rawHex, String merklePath) => BEEF.create(
bumps: [BUMP.fromHex(merklePath)],
txs: [Uint8List.fromList(hexDecode(rawHex))],
hasMerkle: [true],
bumpIndex: [0],
).serialize();
List<int> hexDecode(String hex) => [
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16)
];
String hexEncode(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();

BEEF and BUMP come from package:libspiffy/libspiffy.dart.

bin/first_payment.dart (step 4)
// 4.1 A receive address.
final addr = await coordinator
.ask(GenerateAddressCommand(walletId: 'alice', label: 'faucet'));
print('Alice receives at ${addr.address}');

The reply is an AddressGeneratedEvent. The label is stored with the address, which is useful when you list addresses later.

// 4.2 Coins from the faucet, through Arcade.
final sent = await faucetSend(addr.address!, 1000000);
print('Faucet sent 1000000 sats in ${sent.txid}');
// 4.3 Mine it once a miner holds it.
await waitForArcade(sent.txid, {'SEEN_ON_NETWORK', 'SEEN_MULTIPLE_NODES'});
await mineBlock();
final mined = await waitForArcade(sent.txid, {'MINED', 'IMMUTABLE'});
final height = mined['blockHeight'] as int;
print('Mined at height $height');

Arcade moves a transaction through RECEIVED, ACCEPTED_BY_NETWORK and SEEN_ON_NETWORK to MINED. Wait for SEEN_ON_NETWORK before mining: a block mined before the transaction reaches a subtree doesn’t include it.

// 4.4 Wait for Alice's headers to reach that block.
while (libspiffy.headerChain.bestHeight < height) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}

Alice’s wallet checks the proof against the block header it synced itself from port 18444. Until it holds that header, it has nothing to check against.

// 4.5 Import it with its merkle proof, then check the balance.
final beef = minedBeef(sent.rawHex, mined['merklePath'] as String);
await coordinator.ask(ImportTransactionCommand(walletId: 'alice', beef: beef));
await printBalance(coordinator, 'alice');

ImportTransactionCommand is answered with a TransactionImportedEvent once Alice’s read model holds the transaction. If the proof doesn’t check out, ask throws a CoordinatorFailure instead.

Add printBalance below main:

Future<void> printBalance(WalletCoordinator coordinator, String walletId) async {
final balance = await coordinator.ask(GetBalanceQuery(walletId: walletId));
print('$walletId: ${balance.totalBalance} sats '
'(confirmed ${balance.confirmedBalance}, '
'unconfirmed ${balance.unconfirmedBalance})');
}
Alice receives at mu343b2opCEdXpDRT4wXjnC8qHfee5SqkB
Faucet sent 1000000 sats in a80c33b869c4ef6d0f33698bc48a0c3eb0bd9ed6d9d84786a59ac5c996e8a77c
Mined at height 486
alice: 1000000 sats (confirmed 1000000, unconfirmed 0)

Alice has 1,000,000 sats, confirmed against her own header chain.

Next: Bob creates an invoice →