Skip to content

Commit fa16240

Browse files
authored
CIS-7/Add distinction in representation of cis7 accounts and tokenholder (#504)
* Add distinction in representation of cis7 accounts and tokenholder * Simplify `Token` function input by taking `AccountAddress.Type` instead of its CBOR wrapper * Check token decimals for create plt payload
1 parent 09709e5 commit fa16240

27 files changed

+568
-376
lines changed

examples/nodejs/plt/modify-list.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
import { ConcordiumGRPCNodeClient } from '@concordium/web-sdk/nodejs';
1212
import {
1313
Cbor,
14+
CborAccountAddress,
1415
Token,
15-
TokenHolder,
1616
TokenId,
1717
TokenListUpdate,
1818
TokenOperation,
@@ -107,7 +107,7 @@ const client = new ConcordiumGRPCNodeClient(
107107

108108
// parse the arguments
109109
const tokenId = TokenId.fromString(id);
110-
const targetAddress = TokenHolder.fromAccountAddress(AccountAddress.fromBase58(address));
110+
const targetAddress = AccountAddress.fromBase58(address);
111111

112112
if (walletFile !== undefined) {
113113
// Read wallet-file
@@ -173,7 +173,7 @@ const client = new ConcordiumGRPCNodeClient(
173173
const operationType = `${action}-${list}-list` as TokenOperationType;
174174
// Or from a wallet perspective:
175175
// Create list payload. The payload is the same for both add and remove operations on all lists.
176-
const listPayload: TokenListUpdate = { target: targetAddress };
176+
const listPayload: TokenListUpdate = { target: CborAccountAddress.fromAccountAddress(targetAddress) };
177177
const listOperation = {
178178
[operationType]: listPayload,
179179
} as TokenOperation; // Normally the cast is not necessary unless done in the same dynamic way as here.

examples/nodejs/plt/transfer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import {
1010
import { ConcordiumGRPCNodeClient } from '@concordium/web-sdk/nodejs';
1111
import {
1212
Cbor,
13+
CborAccountAddress,
1314
CborMemo,
1415
Token,
1516
TokenAmount,
16-
TokenHolder,
1717
TokenId,
1818
TokenTransfer,
1919
TokenTransferOperation,
@@ -95,10 +95,10 @@ const client = new ConcordiumGRPCNodeClient(
9595
const tokenId = TokenId.fromString(cli.flags.tokenId);
9696
const token = await Token.fromId(client, tokenId);
9797
const amount = TokenAmount.fromDecimal(cli.flags.amount, token.info.state.decimals);
98-
const recipient = TokenHolder.fromAccountAddress(AccountAddress.fromBase58(cli.flags.recipient));
98+
const recipient = AccountAddress.fromBase58(cli.flags.recipient);
9999
const memo = cli.flags.memo ? CborMemo.fromString(cli.flags.memo) : undefined;
100100

101-
const transfer: TokenTransfer = {
101+
const transfer: Token.TransferInput = {
102102
recipient,
103103
amount,
104104
memo,
@@ -144,6 +144,11 @@ const client = new ConcordiumGRPCNodeClient(
144144
} else {
145145
// Or from a wallet perspective:
146146
// Create transfer payload
147+
const transfer: TokenTransfer = {
148+
recipient: CborAccountAddress.fromAccountAddress(recipient),
149+
amount,
150+
memo,
151+
};
147152
const transferOperation: TokenTransferOperation = {
148153
transfer,
149154
};

packages/sdk/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
- `createCredentialDeploymentTransaction` -> `createCredentialDeploymentPayload`
2222
- `createCredentialTransaction` -> `createCredentialPayload`
2323
- `createCredentialTransactionNoSeed` -> `createCredentialPayloadNoSeed`
24+
- `CborAccountAddress` is now used instead of `TokenHolder` for CBOR encoded account addresses in PLT/CIS-7.
25+
- `Token` functions now take `AccountAddress` anywhere `TokenHolder` was previously used.
2426

2527
#### GRPC API query response types
2628

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { Tag, decode } from 'cbor2';
2+
import { encode, registerEncoder } from 'cbor2/encoder';
3+
4+
import { Base58String } from '../index.js';
5+
import { AccountAddress } from '../types/index.js';
6+
import { bail } from '../util.js';
7+
8+
const CCD_NETWORK_ID = 919; // Concordium network identifier - Did you know 919 is a palindromic prime and a centred hexagonal number?
9+
10+
/** JSON representation of a {@link Type}. */
11+
export type JSON = {
12+
/** The address of the account holding the token. */
13+
address: Base58String;
14+
/** Optional coininfo describing the network for the account. */
15+
coinInfo?: typeof CCD_NETWORK_ID;
16+
};
17+
18+
class CborAccountAddress {
19+
readonly #coinInfo: typeof CCD_NETWORK_ID | undefined;
20+
21+
constructor(
22+
/** The address of the account holding the token. */
23+
public readonly address: AccountAddress.Type,
24+
/** Optional coininfo describing the network for the account. */
25+
coinInfo: typeof CCD_NETWORK_ID | undefined = CCD_NETWORK_ID
26+
) {
27+
this.#coinInfo = coinInfo;
28+
}
29+
30+
public toString(): string {
31+
return this.address.toString();
32+
}
33+
34+
/**
35+
* Get a JSON-serializable representation of the account address. This is called implicitly when serialized with JSON.stringify.
36+
* @returns {JSON} The JSON representation.
37+
*/
38+
public toJSON(): JSON {
39+
return {
40+
address: this.address.toJSON(),
41+
coinInfo: this.#coinInfo,
42+
};
43+
}
44+
}
45+
46+
/**
47+
* Public type alias for the CBOR aware AccountAddress wrapper.
48+
* Instances are created via the helper factory functions rather than the class constructor.
49+
*/
50+
export type Type = CborAccountAddress;
51+
52+
/**
53+
* Construct a {@link Type} from an existing {@link AccountAddress.Type}.
54+
* Coin information will default to the Concordium network id (919).
55+
*/
56+
export function fromAccountAddress(address: AccountAddress.Type): CborAccountAddress {
57+
return new CborAccountAddress(address);
58+
}
59+
60+
/**
61+
* Recreate a {@link Type} from its JSON form.
62+
* @throws {Error} If the supplied coinInfo is present and not the Concordium network id.
63+
*/
64+
export function fromJSON(json: JSON): Type {
65+
if (json.coinInfo !== undefined && json.coinInfo !== CCD_NETWORK_ID) {
66+
throw new Error(`Unsupported coin info for account address: ${json.coinInfo}. Expected ${CCD_NETWORK_ID}.`);
67+
}
68+
return new CborAccountAddress(AccountAddress.fromJSON(json.address), json.coinInfo);
69+
}
70+
71+
/**
72+
* Construct a CborAccountAddress from a base58check string.
73+
*
74+
* @param {string} address String of base58check encoded account address, must use a byte version of 1.
75+
* @returns {CborAccountAddress} The CborAccountAddress.
76+
* @throws If the provided string is not: exactly 50 characters, a valid base58check encoding using version byte 1.
77+
*/
78+
export function fromBase58(address: string): CborAccountAddress {
79+
return fromAccountAddress(AccountAddress.fromBase58(address));
80+
}
81+
82+
/**
83+
* Get a base58check string of the account address.
84+
* @param {CborAccountAddress} accountAddress The account address.
85+
*/
86+
export function toBase58(accountAddress: CborAccountAddress): string {
87+
return accountAddress.address.address;
88+
}
89+
90+
/**
91+
* Type predicate which checks if a value is an instance of {@linkcode Type}
92+
*/
93+
export function instanceOf(value: unknown): value is Type {
94+
return value instanceof CborAccountAddress;
95+
}
96+
97+
// CBOR
98+
const TAGGED_ADDRESS = 40307;
99+
const TAGGED_COININFO = 40305;
100+
101+
/**
102+
* Converts an {@linkcode Account} to a CBOR tagged value.
103+
* This encodes the account address as a CBOR tagged value with tag 40307, containing both
104+
* the coin information (tagged as 40305) and the account's decoded address.
105+
*/
106+
function toCBORValue(value: Type): Tag {
107+
const taggedCoinInfo = new Tag(TAGGED_COININFO, new Map([[1, CCD_NETWORK_ID]]));
108+
const map = new Map<number, any>([
109+
[1, taggedCoinInfo],
110+
[3, value.address.decodedAddress],
111+
]);
112+
return new Tag(TAGGED_ADDRESS, map);
113+
}
114+
115+
/**
116+
* Converts an CborAccountAddress to its CBOR (Concise Binary Object Representation) encoding.
117+
* This encodes the account address as a CBOR tagged value with tag 40307, containing both
118+
* the coin information (tagged as 40305) and the account's decoded address.
119+
*
120+
* This corresponds to a concordium-specific subtype of the `tagged-address` type from
121+
* [BCR-2020-009]{@link https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-009-address.md},
122+
* identified by `tagged-coininfo` corresponding to the Concordium network from
123+
* [BCR-2020-007]{@link https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-007-hdkey.md}
124+
*
125+
* Example of CBOR diagnostic notation for an encoded account address:
126+
* ```
127+
* 40307({
128+
* 1: 40305({1: 919}),
129+
* 3: h'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'
130+
* })
131+
* ```
132+
* Where 919 is the Concordium network identifier and the hex string is the raw account address.
133+
*
134+
* @param {Type} value - The token holder to convert to CBOR format.
135+
* @throws {Error} - If an unsupported CBOR encoding is specified.
136+
* @returns {Uint8Array} The CBOR encoded representation of the token holder.
137+
*/
138+
export function toCBOR(value: Type): Uint8Array {
139+
return new Uint8Array(encode(toCBORValue(value)));
140+
}
141+
142+
/**
143+
* Registers a CBOR encoder for the CborAccountAddress type with the `cbor2` library.
144+
* This allows CborAccountAddress instances to be automatically encoded when used with
145+
* the `cbor2` library's encode function.
146+
*
147+
* @returns {void}
148+
* @example
149+
* // Register the encoder
150+
* registerCBOREncoder();
151+
* // Now CborAccountAddress instances can be encoded directly
152+
* const encoded = encode(myCborAccountAddress);
153+
*/
154+
export function registerCBOREncoder(): void {
155+
registerEncoder(CborAccountAddress, (value) => [TAGGED_ADDRESS, toCBORValue(value).contents]);
156+
}
157+
158+
/**
159+
* Decodes a CBOR-encoded token holder account into an {@linkcode Account} instance.
160+
* @param {unknown} decoded - The CBOR decoded value, expected to be a tagged value with tag 40307.
161+
* @throws {Error} - If the decoded value is not a valid CBOR encoded token holder account.
162+
* @returns {Account} The decoded account address as a CborAccountAddress instance.
163+
*/
164+
function fromCBORValueAccount(decoded: unknown): CborAccountAddress {
165+
// Verify we have a tagged value with tag 40307 (tagged-address)
166+
if (!(decoded instanceof Tag) || decoded.tag !== TAGGED_ADDRESS) {
167+
throw new Error(`Invalid CBOR encoded token holder account: expected tag ${TAGGED_ADDRESS}`);
168+
}
169+
170+
const value = decoded.contents;
171+
172+
if (!(value instanceof Map)) {
173+
throw new Error('Invalid CBOR encoded token holder account: expected a map');
174+
}
175+
176+
// Verify the map corresponds to the BCR-2020-009 `address` format
177+
const validKeys = [1, 2, 3]; // we allow 2 here, as it is in the spec for BCR-2020-009 `address`, but we don't use it
178+
for (const key of value.keys()) {
179+
validKeys.includes(key) || bail(`Invalid CBOR encoded token holder account: unexpected key ${key}`);
180+
}
181+
182+
// Extract the token holder account bytes (key 3)
183+
const addressBytes = value.get(3);
184+
if (
185+
!addressBytes ||
186+
!(addressBytes instanceof Uint8Array) ||
187+
addressBytes.byteLength !== AccountAddress.BYTES_LENGTH
188+
) {
189+
throw new Error('Invalid CBOR encoded account address: missing or invalid address bytes');
190+
}
191+
192+
// Optional validation for coin information if present (key 1)
193+
const coinInfo = value.get(1);
194+
if (coinInfo !== undefined) {
195+
// Verify coin info has the correct tag if present
196+
if (!(coinInfo instanceof Tag) || coinInfo.tag !== TAGGED_COININFO) {
197+
throw new Error(
198+
`Invalid CBOR encoded token holder account: coin info has incorrect tag (expected ${TAGGED_COININFO})`
199+
);
200+
}
201+
202+
// Verify coin info contains Concordium network identifier if present
203+
const coinInfoMap = coinInfo.contents;
204+
if (!(coinInfoMap instanceof Map) || coinInfoMap.get(1) !== CCD_NETWORK_ID) {
205+
throw new Error(
206+
`Invalid CBOR token holder account: coin info does not contain Concordium network identifier ${CCD_NETWORK_ID}`
207+
);
208+
}
209+
210+
// Verify the map corresponds to the BCR-2020-007 `coininfo` format
211+
const validKeys = [1, 2]; // we allow 2 here, as it is in the spec for BCR-2020-007 `coininfo`, but we don't use it
212+
for (const key of coinInfoMap.keys()) {
213+
validKeys.includes(key) || bail(`Invalid CBOR encoded coininfo: unexpected key ${key}`);
214+
}
215+
}
216+
217+
// Create the AccountAddress from the extracted bytes
218+
return fromAccountAddress(AccountAddress.fromBuffer(addressBytes));
219+
}
220+
221+
/**
222+
* Decodes a CBOR value into a CborAccountAddress instance.
223+
* This function checks if the value is a tagged address (40307) and decodes it accordingly.
224+
*
225+
* @param {unknown} value - The CBOR decoded value, expected to be a tagged address.
226+
* @throws {Error} - If the value is not a valid CBOR encoded token holder account.
227+
* @returns {Type} The decoded CborAccountAddress instance.
228+
*/
229+
export function fromCBORValue(value: unknown): Type {
230+
if (value instanceof Tag && value.tag === TAGGED_ADDRESS) {
231+
return fromCBORValueAccount(value);
232+
}
233+
234+
throw new Error(`Failed to decode 'CborAccountAddress.Type' from CBOR value: ${value}`);
235+
}
236+
237+
/**
238+
* Decodes a CBOR-encoded account address into an CborAccountAddress instance.
239+
* This function can handle both the full tagged format (with coin information)
240+
* and a simplified format with just the address bytes.
241+
*
242+
* 1. With `tagged-coininfo` (40305):
243+
* ```
244+
* 40307({
245+
* 1: 40305({1: 919}), // Optional coin information
246+
* 3: h'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'
247+
* })
248+
* ```
249+
*
250+
* 2. Without `tagged-coininfo`:
251+
* ```
252+
* 40307({
253+
* 3: h'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'
254+
* }) // The address is assumed to be a Concordium address
255+
* ```
256+
*
257+
* @param {Uint8Array} bytes - The CBOR encoded representation of an account address.
258+
* @throws {Error} - If the input is not a valid CBOR encoding of an account address.
259+
* @returns {Type} The decoded CborAccountAddress instance.
260+
*/
261+
export function fromCBOR(bytes: Uint8Array): Type {
262+
return fromCBORValue(decode(bytes));
263+
}
264+
265+
/**
266+
* Registers a CBOR decoder for the tagged-address (40307) format with the `cbor2` library.
267+
* This enables automatic decoding of CBOR data containing Concordium account addresses
268+
* when using the `cbor2` library's decode function.
269+
*
270+
* @returns {() => void} A cleanup function that, when called, will restore the previous
271+
* decoder (if any) that was registered for the tagged-address format. This is useful
272+
* when used in an existing `cbor2` use-case.
273+
*
274+
* @example
275+
* // Register the decoder
276+
* const cleanup = registerCBORDecoder();
277+
* // Use the decoder
278+
* const tokenHolder = decode(cborBytes); // Returns CborAccountAddress if format matches
279+
* // Later, unregister the decoder
280+
* cleanup();
281+
*/
282+
export function registerCBORDecoder(): () => void {
283+
const old = [Tag.registerDecoder(TAGGED_ADDRESS, fromCBORValue)];
284+
285+
// return cleanup function to restore the old decoder
286+
return () => {
287+
for (const decoder of old) {
288+
if (decoder) {
289+
Tag.registerDecoder(TAGGED_ADDRESS, decoder);
290+
} else {
291+
Tag.clearDecoder(TAGGED_ADDRESS);
292+
}
293+
}
294+
};
295+
}

0 commit comments

Comments
 (0)