Skip to content

Commit

Permalink
Add CoinUnit class to convert satoshis and coin units
Browse files Browse the repository at this point in the history
  • Loading branch information
MatthewLM committed Jan 14, 2024
1 parent 639ded7 commit 6760f81
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 0 deletions.
73 changes: 73 additions & 0 deletions coinlib/lib/src/coin_unit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/// Thrown when a number does not match the expected format for a given
/// [CoinUnit].
class BadAmountString implements Exception {}

/// Objects of this class represent a coin denomination with a given number of
/// [decimals]. Use [coin] for whole coins with 6 decimal places and [sats] for
/// the smallest unit with no decimal places.
class CoinUnit {

static final _numberRegex = RegExp(r"^\d+(\.\d+)?$");
static final _trailZeroRegex = RegExp(r"\.?0*$");

/// The number of decimal places for this unit
final int decimals;
/// The number of satoshis per unit
final BigInt satsPerUnit;

/// Creates a unit with a given number of [decimals].
CoinUnit(this.decimals) : satsPerUnit = BigInt.from(10).pow(decimals);

/// Obtains the number of satoshis from a string representation of this unit.
///
/// Numbers must only contain digits and optionally one decimal point (".") in
/// the event that there are any decimals. Ensure that there is at least one
/// digit before and after the decimal point. There may only be decimals upto
/// [decimals] in number. Zeros are striped from the left and stripped from
/// the right after the decimal point.
///
/// May throw [BadAmountString] if the number is not formatted correctly.
BigInt toSats(String amount) {

// Check format
if (!_numberRegex.hasMatch(amount)) throw BadAmountString();

// Split decimal
final split = amount.split(".");
final includesPoint = split.length == 2;

// Decimal places must not exceed expected decimals
if (includesPoint && split[1].length > decimals) throw BadAmountString();

// Parse both sides into BigInt
final left = BigInt.parse(split[0]);
final right = includesPoint
? BigInt.parse(split[1].padRight(decimals, "0"))
: BigInt.zero;

return left*satsPerUnit + right;

}

/// Obtains the string representation of the satoshis ([sats]) converted into
/// this unit.
String fromSats(BigInt sats) {

final padded = sats.toString().padLeft(decimals+1, "0");
final insertIdx = padded.length-decimals;
final left = padded.substring(0, insertIdx);
final right = padded.substring(insertIdx);
final withPoint = "$left.$right";

// Remove any trailing zeros and the decimal point if it comes before those
// zeros
return withPoint.replaceFirst(_trailZeroRegex, "");

}

/// Represents a whole coin with 6 decimal places
static final coin = CoinUnit(6);
/// Represents a satoshi
static final sats = CoinUnit(0);

}
1 change: 1 addition & 0 deletions coinlib/lib/src/coinlib_base.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export 'package:coinlib/src/tx/sighash/taproot_signature_hasher.dart';
export 'package:coinlib/src/tx/sighash/witness_signature_hasher.dart';

export 'package:coinlib/src/address.dart';
export 'package:coinlib/src/coin_unit.dart';
export 'package:coinlib/src/network_params.dart';
export 'package:coinlib/src/taproot.dart';

Expand Down
76 changes: 76 additions & 0 deletions coinlib/test/coin_unit_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'package:coinlib/coinlib.dart';
import 'package:test/test.dart';

void main() {

group("CoinUnit", () {

void expectValidConversion(
CoinUnit unit, String original, String sats, String result,
) {
final actualSats = unit.toSats(original);
expect(actualSats, BigInt.parse(sats));
expect(unit.fromSats(actualSats), result);
}

void expectInvalid(CoinUnit unit, String str) => expect(
() => unit.toSats(str), throwsA(isA<BadAmountString>()),
);

test("valid coin", () {

void expectCoin(String original, String sats, String result)
=> expectValidConversion(CoinUnit.coin, original, sats, result);

expectCoin("0", "0", "0");
expectCoin("0.0", "0", "0");
expectCoin("0.000000", "0", "0");
expectCoin("000.000000", "0", "0");

expectCoin("1", "1000000", "1");
expectCoin("001", "1000000", "1");
expectCoin("1.000000", "1000000", "1");

expectCoin("1.123456", "1123456", "1.123456");
expectCoin("1.123", "1123000", "1.123");
expectCoin("1.123000", "1123000", "1.123");
expectCoin("0.000001", "1", "0.000001");
expectCoin("020.000001", "20000001", "20.000001");

});

test("valid sats", () {

void expectSats(String original, String sats)
=> expectValidConversion(CoinUnit.sats, original, sats, sats);

expectSats("0", "0");
expectSats("000", "0");
expectSats("1", "1");
expectSats("00100", "100");
expectSats("1234567890", "1234567890");
expectSats("012345678090", "12345678090");

});

test("invalid coin", () {
for (final invalid in [
"0.", ".123456", ".", "0.1.2", " 1", "1 ", "1 000", "1,000", "0.1234567",
"1.1234560", "0a", "0A", "A0", "1/2", "one", "-1", "-0",
]) {
expectInvalid(CoinUnit.coin, invalid);
}
});

test("invalid sats", () {
for (final invalid in [
"0.", ".123456", ".", "0.1", "0.1.2", " 1", "1 ", "1 000", "1,000",
"0a", "0A", "A0",
]) {
expectInvalid(CoinUnit.sats, invalid);
}
});

});

}

0 comments on commit 6760f81

Please sign in to comment.