Skip to content

Commit 6760f81

Browse files
committed
Add CoinUnit class to convert satoshis and coin units
1 parent 639ded7 commit 6760f81

File tree

3 files changed

+150
-0
lines changed

3 files changed

+150
-0
lines changed

coinlib/lib/src/coin_unit.dart

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/// Thrown when a number does not match the expected format for a given
2+
/// [CoinUnit].
3+
class BadAmountString implements Exception {}
4+
5+
/// Objects of this class represent a coin denomination with a given number of
6+
/// [decimals]. Use [coin] for whole coins with 6 decimal places and [sats] for
7+
/// the smallest unit with no decimal places.
8+
class CoinUnit {
9+
10+
static final _numberRegex = RegExp(r"^\d+(\.\d+)?$");
11+
static final _trailZeroRegex = RegExp(r"\.?0*$");
12+
13+
/// The number of decimal places for this unit
14+
final int decimals;
15+
/// The number of satoshis per unit
16+
final BigInt satsPerUnit;
17+
18+
/// Creates a unit with a given number of [decimals].
19+
CoinUnit(this.decimals) : satsPerUnit = BigInt.from(10).pow(decimals);
20+
21+
/// Obtains the number of satoshis from a string representation of this unit.
22+
///
23+
/// Numbers must only contain digits and optionally one decimal point (".") in
24+
/// the event that there are any decimals. Ensure that there is at least one
25+
/// digit before and after the decimal point. There may only be decimals upto
26+
/// [decimals] in number. Zeros are striped from the left and stripped from
27+
/// the right after the decimal point.
28+
///
29+
/// May throw [BadAmountString] if the number is not formatted correctly.
30+
BigInt toSats(String amount) {
31+
32+
// Check format
33+
if (!_numberRegex.hasMatch(amount)) throw BadAmountString();
34+
35+
// Split decimal
36+
final split = amount.split(".");
37+
final includesPoint = split.length == 2;
38+
39+
// Decimal places must not exceed expected decimals
40+
if (includesPoint && split[1].length > decimals) throw BadAmountString();
41+
42+
// Parse both sides into BigInt
43+
final left = BigInt.parse(split[0]);
44+
final right = includesPoint
45+
? BigInt.parse(split[1].padRight(decimals, "0"))
46+
: BigInt.zero;
47+
48+
return left*satsPerUnit + right;
49+
50+
}
51+
52+
/// Obtains the string representation of the satoshis ([sats]) converted into
53+
/// this unit.
54+
String fromSats(BigInt sats) {
55+
56+
final padded = sats.toString().padLeft(decimals+1, "0");
57+
final insertIdx = padded.length-decimals;
58+
final left = padded.substring(0, insertIdx);
59+
final right = padded.substring(insertIdx);
60+
final withPoint = "$left.$right";
61+
62+
// Remove any trailing zeros and the decimal point if it comes before those
63+
// zeros
64+
return withPoint.replaceFirst(_trailZeroRegex, "");
65+
66+
}
67+
68+
/// Represents a whole coin with 6 decimal places
69+
static final coin = CoinUnit(6);
70+
/// Represents a satoshi
71+
static final sats = CoinUnit(0);
72+
73+
}

coinlib/lib/src/coinlib_base.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export 'package:coinlib/src/tx/sighash/taproot_signature_hasher.dart';
5757
export 'package:coinlib/src/tx/sighash/witness_signature_hasher.dart';
5858

5959
export 'package:coinlib/src/address.dart';
60+
export 'package:coinlib/src/coin_unit.dart';
6061
export 'package:coinlib/src/network_params.dart';
6162
export 'package:coinlib/src/taproot.dart';
6263

coinlib/test/coin_unit_test.dart

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import 'package:coinlib/coinlib.dart';
2+
import 'package:test/test.dart';
3+
4+
void main() {
5+
6+
group("CoinUnit", () {
7+
8+
void expectValidConversion(
9+
CoinUnit unit, String original, String sats, String result,
10+
) {
11+
final actualSats = unit.toSats(original);
12+
expect(actualSats, BigInt.parse(sats));
13+
expect(unit.fromSats(actualSats), result);
14+
}
15+
16+
void expectInvalid(CoinUnit unit, String str) => expect(
17+
() => unit.toSats(str), throwsA(isA<BadAmountString>()),
18+
);
19+
20+
test("valid coin", () {
21+
22+
void expectCoin(String original, String sats, String result)
23+
=> expectValidConversion(CoinUnit.coin, original, sats, result);
24+
25+
expectCoin("0", "0", "0");
26+
expectCoin("0.0", "0", "0");
27+
expectCoin("0.000000", "0", "0");
28+
expectCoin("000.000000", "0", "0");
29+
30+
expectCoin("1", "1000000", "1");
31+
expectCoin("001", "1000000", "1");
32+
expectCoin("1.000000", "1000000", "1");
33+
34+
expectCoin("1.123456", "1123456", "1.123456");
35+
expectCoin("1.123", "1123000", "1.123");
36+
expectCoin("1.123000", "1123000", "1.123");
37+
expectCoin("0.000001", "1", "0.000001");
38+
expectCoin("020.000001", "20000001", "20.000001");
39+
40+
});
41+
42+
test("valid sats", () {
43+
44+
void expectSats(String original, String sats)
45+
=> expectValidConversion(CoinUnit.sats, original, sats, sats);
46+
47+
expectSats("0", "0");
48+
expectSats("000", "0");
49+
expectSats("1", "1");
50+
expectSats("00100", "100");
51+
expectSats("1234567890", "1234567890");
52+
expectSats("012345678090", "12345678090");
53+
54+
});
55+
56+
test("invalid coin", () {
57+
for (final invalid in [
58+
"0.", ".123456", ".", "0.1.2", " 1", "1 ", "1 000", "1,000", "0.1234567",
59+
"1.1234560", "0a", "0A", "A0", "1/2", "one", "-1", "-0",
60+
]) {
61+
expectInvalid(CoinUnit.coin, invalid);
62+
}
63+
});
64+
65+
test("invalid sats", () {
66+
for (final invalid in [
67+
"0.", ".123456", ".", "0.1", "0.1.2", " 1", "1 ", "1 000", "1,000",
68+
"0a", "0A", "A0",
69+
]) {
70+
expectInvalid(CoinUnit.sats, invalid);
71+
}
72+
});
73+
74+
});
75+
76+
}

0 commit comments

Comments
 (0)