diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e692ca..d38d1421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ that can be found in the LICENSE file. --> # Changelog +## 1.0.0-dev.24 + +- Implement subaccount as `Principal.subAccount`, which also removes the `subAccount` parameter + when converting a principal to an Account ID. Some other constructors are also removed due to duplicates. + ## 1.0.0-dev.23 - Fix encoder with deps and format files. diff --git a/lib/agent/auth.dart b/lib/agent/auth.dart index 1199586f..417b1d58 100644 --- a/lib/agent/auth.dart +++ b/lib/agent/auth.dart @@ -49,10 +49,8 @@ abstract class SignIdentity implements Identity { /// Signs a blob of data, with this identity's private key. Future sign(BinaryBlob blob); - Uint8List getAccountId([Uint8List? subAccount]) { - return Principal.selfAuthenticating( - getPublicKey().toDer(), - ).toAccountId(subAccount: subAccount); + Uint8List getAccountId() { + return Principal.selfAuthenticating(getPublicKey().toDer()).toAccountId(); } /// Get the principal represented by this identity. Normally should be a diff --git a/lib/archiver/encoder.dart b/lib/archiver/encoder.dart index 66f27a99..1bb535d6 100644 --- a/lib/archiver/encoder.dart +++ b/lib/archiver/encoder.dart @@ -159,9 +159,9 @@ class SingingBlockZipFileEncoder extends ZipFileEncoder { } @override - Future close() async { + Future close() { _encoder.writeBlock(_output); _encoder.endEncode(); - await _output.close(); + return _output.close(); } } diff --git a/lib/principal/principal.dart b/lib/principal/principal.dart index bdb9bf6d..d935fc45 100644 --- a/lib/principal/principal.dart +++ b/lib/principal/principal.dart @@ -1,6 +1,5 @@ import 'dart:typed_data'; -import 'package:agent_dart/agent/types.dart'; import 'package:agent_dart/utils/extension.dart'; import '../agent/errors.dart'; @@ -15,8 +14,14 @@ const _suffixAnonymous = 4; const _maxLengthInBytes = 29; const _typeOpaque = 1; +final _emptySubAccount = Uint8List(32); + class Principal { - const Principal(this._arr); + const Principal( + this._principal, { + Uint8List? subAccount, + }) : assert(subAccount == null || subAccount.length == 32), + _subAccount = subAccount; factory Principal.selfAuthenticating(Uint8List publicKey) { final sha = sha224Hash(publicKey.buffer); @@ -28,18 +33,18 @@ class Principal { return Principal(Uint8List.fromList([_suffixAnonymous])); } - factory Principal.from(dynamic other) { + factory Principal.from(Object? other) { if (other is String) { return Principal.fromText(other); } else if (other is Map && other['_isPrincipal'] == true) { - return Principal(other['_arr']); + return Principal(other['_arr'], subAccount: other['_subAccount']); } else if (other is Principal) { - return Principal(other._arr); + return Principal(other._principal, subAccount: other.subAccount); } throw UnreachableError(); } - factory Principal.create(int uSize, Uint8List data) { + factory Principal.create(int uSize, Uint8List data, Uint8List? subAccount) { if (uSize > data.length) { throw RangeError.range( uSize, @@ -49,56 +54,112 @@ class Principal { 'Size must within the data length', ); } - return Principal.fromBlob(data.sublist(0, uSize)); + return Principal(data.sublist(0, uSize), subAccount: subAccount); } - factory Principal.fromHex(String hex) { + factory Principal.fromHex(String hex, {String? subAccountHex}) { if (hex.isEmpty) { return Principal(Uint8List(0)); } - return Principal(hex.toU8a()); + if (subAccountHex == null || subAccountHex.isEmpty) { + subAccountHex = null; + } else if (subAccountHex.startsWith('0')) { + throw ArgumentError.value( + subAccountHex, + 'subAccountHex', + 'The representation is not canonical: ' + 'leading zeros are not allowed in subaccounts.', + ); + } + return Principal( + hex.toU8a(), + subAccount: subAccountHex?.padLeft(64, '0').toU8a(), + ); } factory Principal.fromText(String text) { - final canisterIdNoDash = text.toLowerCase().replaceAll('-', ''); + if (text.endsWith('.')) { + throw ArgumentError( + 'The representation is not canonical: ' + 'default subaccount should be omitted.', + ); + } + final paths = text.split('.'); + final String? subAccountHex; + if (paths.length > 1) { + subAccountHex = paths.last; + } else { + subAccountHex = null; + } + if (subAccountHex != null && subAccountHex.startsWith('0')) { + throw ArgumentError.value( + subAccountHex, + 'subAccount', + 'The representation is not canonical: ' + 'leading zeros are not allowed in subaccounts.', + ); + } + String prePrincipal = paths.first; + // Removes the checksum if sub-account is valid. + if (subAccountHex != null) { + final list = prePrincipal.split('-'); + final checksum = list.removeLast(); + // Checksum is 7 digits. + if (checksum.length != 7) { + throw ArgumentError.value( + prePrincipal, + 'principal', + 'Missing checksum', + ); + } + prePrincipal = list.join('-'); + } + final canisterIdNoDash = prePrincipal.toLowerCase().replaceAll('-', ''); Uint8List arr = base32Decode(canisterIdNoDash); arr = arr.sublist(4, arr.length); - final principal = Principal(arr); + final subAccount = subAccountHex?.padLeft(64, '0').toU8a(); + final principal = Principal(arr, subAccount: subAccount); if (principal.toText() != text) { throw ArgumentError.value( text, - 'Principal', - 'Principal expected to be ${principal.toText()} but got', + 'principal', + 'The principal is expected to be ${principal.toText()} but got', ); } return principal; } - factory Principal.fromBlob(BinaryBlob arr) { - return Principal.fromUint8Array(arr); - } + final Uint8List _principal; + final Uint8List? _subAccount; - factory Principal.fromUint8Array(Uint8List arr) { - return Principal(arr); + Uint8List? get subAccount { + if (_subAccount case final v when v == null || v.eq(_emptySubAccount)) { + return null; + } + return _subAccount; } - final Uint8List _arr; + Principal newSubAccount(Uint8List? subAccount) { + if (subAccount == null || subAccount.eq(_emptySubAccount)) { + return this; + } + if (this.subAccount == null || !this.subAccount!.eq(subAccount)) { + return Principal(_principal, subAccount: subAccount); + } + return this; + } bool isAnonymous() { - return _arr.lengthInBytes == 1 && _arr[0] == _suffixAnonymous; + return _principal.lengthInBytes == 1 && _principal[0] == _suffixAnonymous; } - Uint8List toUint8List() => _arr; - - Uint8List toBlob() => toUint8List(); + Uint8List toUint8List() => _principal; - String toHex() => _toHexString(_arr).toUpperCase(); + String toHex() => _toHexString(_principal).toUpperCase(); String toText() { - final checksumArrayBuf = ByteData(4); - checksumArrayBuf.setUint32(0, getCrc32(_arr.buffer)); - final checksum = checksumArrayBuf.buffer.asUint8List(); - final bytes = Uint8List.fromList(_arr); + final checksum = _getChecksum(_principal.buffer); + final bytes = Uint8List.fromList(_principal); final array = Uint8List.fromList([...checksum, ...bytes]); final result = base32Encode(array); final reg = RegExp(r'.{1,5}'); @@ -107,21 +168,34 @@ class Principal { // This should only happen if there's no character, which is unreachable. throw StateError('No characters found.'); } - return matches.map((e) => e.group(0)).join('-'); + final buffer = StringBuffer(matches.map((e) => e.group(0)).join('-')); + if (_subAccount case final subAccount? + when !subAccount.eq(_emptySubAccount)) { + final subAccountHex = subAccount.toHex(); + int nonZeroStart = 0; + while (nonZeroStart < subAccountHex.length) { + if (subAccountHex[nonZeroStart] != '0') { + break; + } + nonZeroStart++; + } + if (nonZeroStart != subAccountHex.length) { + final checksum = base32Encode( + _getChecksum(Uint8List.fromList(_principal + subAccount).buffer), + ); + buffer.write('-$checksum'); + buffer.write('.'); + buffer.write(subAccountHex.replaceRange(0, nonZeroStart, '')); + } + } + return buffer.toString(); } - Uint8List toAccountId({Uint8List? subAccount}) { - if (subAccount != null && subAccount.length != 32) { - throw ArgumentError.value( - subAccount, - 'subAccount', - 'Sub-account address must be 32-bytes length', - ); - } + Uint8List toAccountId() { final hash = SHA224(); hash.update('\x0Aaccount-id'.plainToU8a()); - hash.update(toBlob()); - hash.update(subAccount ?? Uint8List(32)); + hash.update(toUint8List()); + hash.update(subAccount ?? _emptySubAccount); final data = hash.digest(); final view = ByteData(4); view.setUint32(0, getCrc32(data.buffer)); @@ -137,14 +211,18 @@ class Principal { @override bool operator ==(Object other) => - identical(this, other) || other is Principal && _arr.eq(other._arr); + identical(this, other) || + other is Principal && + _principal.eq(other._principal) && + (_subAccount?.eq(other._subAccount ?? _emptySubAccount) ?? + _subAccount == null && other._subAccount == null); @override - int get hashCode => _arr.hashCode; + int get hashCode => Object.hash(_principal, subAccount); } class CanisterId extends Principal { - CanisterId(Principal pid) : super(pid.toBlob()); + CanisterId(Principal pid) : super(pid.toUint8List()); factory CanisterId.fromU64(int val) { // It is important to use big endian here to ensure that the generated @@ -164,11 +242,18 @@ class CanisterId extends Principal { data[blobLength] = _typeOpaque; return CanisterId( - Principal.create(blobLength + 1, Uint8List.fromList(data)), + Principal.create(blobLength + 1, Uint8List.fromList(data), null), ); } } +Uint8List _getChecksum(ByteBuffer buffer) { + final checksumArrayBuf = ByteData(4); + checksumArrayBuf.setUint32(0, getCrc32(buffer)); + final checksum = checksumArrayBuf.buffer.asUint8List(); + return checksum; +} + String _toHexString(Uint8List bytes) { return bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); } diff --git a/lib/wallet/rosetta.dart b/lib/wallet/rosetta.dart index 3be0c6dd..f45660cf 100644 --- a/lib/wallet/rosetta.dart +++ b/lib/wallet/rosetta.dart @@ -609,11 +609,10 @@ Map transactionDecoder(String txnHash) { final content = envelope['content'] as Map; final senderPubkey = envelope['sender_pubkey']; final sendArgs = SendRequest.fromBuffer(content['arg']); - final senderAddress = - Principal.fromBlob(Uint8List.fromList(content['sender'])); + final senderAddress = Principal(Uint8List.fromList(content['sender'])); final hash = SHA224() ..update(('\x0Aaccount-id').plainToU8a()) - ..update(senderAddress.toBlob()) + ..update(senderAddress.toUint8List()) ..update(Uint8List(32)); return { 'from': hash.digest(), diff --git a/pubspec.yaml b/pubspec.yaml index 558edc95..0d5a2974 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: | a plugin package for dart and flutter apps. Developers can build ones to interact with Dfinity's blockchain directly. repository: https://github.com/AstroxNetwork/agent_dart -version: 1.0.0-dev.23 +version: 1.0.0-dev.24 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/agent/cbor.dart b/test/agent/cbor.dart index 7b01b2b0..68d50027 100644 --- a/test/agent/cbor.dart +++ b/test/agent/cbor.dart @@ -49,6 +49,6 @@ void cborTest() { final outputA = output['a'] as Uint8Buffer; expect(outputA.toHex(), inputA.toHex()); - expect(Principal.fromUint8Array(outputA.toU8a()).toText(), 'aaaaa-aa'); + expect(Principal(outputA.toU8a()).toText(), 'aaaaa-aa'); }); } diff --git a/test/principal/principal.dart b/test/principal/principal.dart index 66062a9a..e45a06d2 100644 --- a/test/principal/principal.dart +++ b/test/principal/principal.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:agent_dart/principal/principal.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,6 +27,73 @@ void principalTest() { expect(Principal.fromText('aaaaa-aa').toHex(), ''); expect(Principal.fromText('2vxsx-fae').toHex(), '04'); expect(Principal.fromText('2vxsx-fae').isAnonymous(), true); + + // ICRC-1 + expect( + Principal.fromText( + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae', + ).toText(), + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae', + ); + expect( + () => Principal.fromText( + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-q6bn32y.', + ).toText(), + throwsA( + isError( + 'The representation is not canonical: ' + 'default subaccount should be omitted.', + ), + ), + ); + expect( + Principal.fromText( + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1', + ).toText(), + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1', + ); + expect( + () => Principal.fromText( + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.01', + ).toText(), + throwsA( + isError( + 'The representation is not canonical: ' + 'leading zeros are not allowed in subaccounts.', + ), + ), + ); + expect( + () => Principal.fromText( + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae.1', + ).toText(), + throwsA( + isError('Missing checksum'), + ), + ); + expect( + Principal.fromText( + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy' + '.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20', + ).toText(), + 'k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy' + '.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20', + ); + expect( + Principal.fromText( + 'z4s7u-byaaa-aaaao-a3paa-cai-l435n4y' + '.1de15338bedf41b306d4ef5615d1f94fd7b0474b9255690cbe78d2309f02', + ).toText(), + 'z4s7u-byaaa-aaaao-a3paa-cai-l435n4y' + '.1de15338bedf41b306d4ef5615d1f94fd7b0474b9255690cbe78d2309f02', + ); + expect( + Principal( + Uint8List.fromList([0, 0, 0, 0, 0, 224, 17, 26, 1, 1]), + subAccount: Uint8List(32), + ).toText(), + 'lrllq-iqaaa-aaaah-acena-cai', + ); }); test('errors out on invalid checksums', () { @@ -33,20 +102,24 @@ void principalTest() { () => Principal.fromText('0chl6-4hpzw-vqaaa-aaaaa-c').toHex(), throwsA( isError( - 'Principal expected to be 2chl6-4hpzw-vqaaa-aaaaa-c but got', + 'The principal is expected to be 2chl6-4hpzw-vqaaa-aaaaa-c but got', ), ), ); expect( () => Principal.fromText('0aaaa-aa').toHex(), throwsA( - isError('Principal expected to be aaaaa-aa but got'), + isError( + 'The principal is expected to be aaaaa-aa but got', + ), ), ); expect( () => Principal.fromText('0vxsx-fae').toHex(), throwsA( - isError('Principal expected to be 2vxsx-fae but got'), + isError( + 'The principal is expected to be 2vxsx-fae but got', + ), ), ); });