diff --git a/contracts/src/token/MultiToken.compact b/contracts/src/token/MultiToken.compact index 649b8076..918d5bf0 100644 --- a/contracts/src/token/MultiToken.compact +++ b/contracts/src/token/MultiToken.compact @@ -10,13 +10,24 @@ pragma language_version >= 0.21.0; * therefore, the MultiToken module should be treated as an approximation of * the ERC1155 standard and not necessarily a compliant implementation. * + * Authorization is based on a witness-derived identity scheme. Each caller proves knowledge + * of a secret key by injecting it via the `wit_MultiTokenSK` witness. The module computes + * an account identifier as `persistentHash(secretKey)` which is a commitment that hides the key + * while providing a stable, pseudonymous on-chain identity. + * + * Because the account identifier is `H(secretKey)` with no per-deployment salt or domain + * separator, the same secret key produces the same identity across all contracts. This is + * intentional. It provides a linkable pseudonymous identity analogous to Solidity's + * `msg.sender`. Users who desire cross-contract unlinkability can use different secret keys + * per contract at the wallet layer. + * * @notice One notable difference regarding this implementation and the EIP1155 spec * consists of the token size. Uint<128> is used as the token size because Uint<256> * cannot be supported. This is true for both token IDs and for amounts. * This is due to encoding limits on the midnight circuit backend: * https://github.com/midnightntwrk/compactc/issues/929 * - * @notice Some features defined in th EIP1155 spec are NOT included. + * @notice Some features defined in the EIP1155 spec are NOT included. * Such features include: * * 1. Batch mint, burn, transfer - Without support for dynamic arrays, @@ -31,7 +42,7 @@ pragma language_version >= 0.21.0; * explicitly define the number of balances to query in the circuit i.e. * * balanceOfBatch_10( - * accounts: Vector<10, Either>, + * accounts: Vector<10, Either, ContractAddress>>, * ids: Vector<10, Uint<128>> * ): Vector<10, Uint<128>> * @@ -57,6 +68,25 @@ pragma language_version >= 0.21.0; * - Introspection. * - Contract-to-contract calls for acceptance callback. * + * @dev Canonicalization + * All `Either, ContractAddress>` values are canonicalized before use as map keys + * or in ledger writes. Canonicalization zeroes out the inactive branch of the Either, + * ensuring that two values with the same active branch always resolve to the same map key + * regardless of what data the inactive branch carries. Write paths are canonicalized in + * `_update` (for `_balances`) and `_setApprovalForAll` (for `_operatorApprovals`). + * Read paths are canonicalized in `balanceOf` and `isApprovedForAll`. + * `_unsafeTransferFrom` canonicalizes `fromAddress` before comparing against the caller identity. + * + * @dev Security Considerations: + * - The `secretKey` must be kept private. Loss of the key prevents token holders + * from proving ownership or authorization. Key exposure allows impersonation. + * - It is strongly recommended to use cryptographically secure random values for the secret key + * (e.g., `crypto.getRandomValues()`). Weak or predictable keys can be brute-forced. + * - The `secretKey` is provided via the `wit_MultiTokenSK` witness. As with all witnesses, + * the contract must not assume that the witness implementation matches the developer's code. + * Any DApp may provide any implementation. The ZK proof system constrains what values + * can produce valid proofs. + * * @notice Further discussion and consideration required: * * - Consider changing the underscore in the internal methods to `unsafe` or @@ -71,24 +101,24 @@ module MultiToken { /** * @description Mapping from token ID to account balances. * @type {Uint<128>} id - The token identifier. - * @type {Either} account - The account address. + * @type {Either, ContractAddress>} account - The account address. * @type {Uint<128>} balance - The balance of the account for the token. * @type {Map>} - * @type {Map, Map, Uint<128>>>} _balances + * @type {Map, Map, ContractAddress>, Uint<128>>>} _balances */ export ledger _balances: Map, - Map, Uint<128>>>; + Map, ContractAddress>, Uint<128>>>; /** * @description Mapping from account to operator approvals. - * @type {Either} account - The account address. - * @type {Either} operator - The operator address. + * @type {Either, ContractAddress>} account - The account address. + * @type {Either, ContractAddress>} operator - The operator address. * @type {Boolean} approved - The approval status of the operator for the account. * @type {Map>} - * @type {Map, Map, Boolean>>} + * @type {Map, ContractAddress>, Map, ContractAddress>, Boolean>>} */ - export ledger _operatorApprovals: Map, - Map, Boolean>>; + export ledger _operatorApprovals: Map, ContractAddress>, + Map, ContractAddress>, Boolean>>; /** * @description Base URI for computing token URIs. @@ -97,10 +127,40 @@ module MultiToken { */ export ledger _uri: Opaque<"string">; + /** + * @witness wit_MultiTokenSK + * @description Returns the caller's secret key used in deriving the account identifier. + * + * The same key produces the same account identifier across all contracts. Users who + * desire cross-contract unlinkability should use different keys per contract. + * + * @returns {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + */ + witness wit_MultiTokenSK(): Bytes<32>; + + /** + * @description Returns a canonical zero Either value (left variant with zero Bytes<32>). + * Used as the zero value for mint/burn operations in `_update`, where a zero + * `fromAddress` signals a mint and a zero `to` signals a burn. + * + * The left variant is chosen so that `_isTargetZero` checks against `default>`, + * which is consistent with how `_update` dispatches on the zero check. A right variant + * with `default` would also pass `_isTargetZero`, but using the left + * variant avoids triggering contract address guards in circuits like `_mint` that check + * `!to.is_left` before delegating to `_unsafeMint`. + * + * @return {Either, ContractAddress>} - The zero value. + */ + export pure circuit ZERO(): Either, ContractAddress> { + return Either, ContractAddress> { + is_left: true, left: default>, right: default + }; + } + /** * @description Initializes the contract by setting the base URI for all tokens. * - * @circuitInfo k=10, rows=45 + * @circuitInfo k=6, rows=33 * * Requirements: * @@ -121,135 +181,139 @@ module MultiToken { * Clients calling this function must replace the `\{id\}` substring with the * actual token type ID. * - * @circuitInfo k=10, rows=90 + * @circuitInfo k=8, rows=176 * * Requirements: * * - Contract is initialized. * * @param {Uint<128>} id - The token identifier to query. - * return {Opaque<"string">} - The base URI for all tokens. + * @return {Opaque<"string">} - The base URI for all tokens. */ export circuit uri(id: Uint<128>): Opaque<"string"> { Initializable_assertInitialized(); - return _uri; } /** * @description Returns the amount of `id` tokens owned by `account`. * - * @circuitInfo k=10, rows=439 + * @circuitInfo k=10, rows=875 * * Requirements: * * - Contract is initialized. * - * @param {Either} account - The account balance to query. + * @param {Either, ContractAddress>} account - The account balance to query. * @param {Uint<128>} id - The token identifier to query. - * return {Uint<128>} - The quantity of `id` tokens that `account` owns. + * @return {Uint<128>} - The quantity of `id` tokens that `account` owns. */ export circuit balanceOf( - account: Either, + account: Either, ContractAddress>, id: Uint<128> ): Uint<128> { Initializable_assertInitialized(); + const canonAcct = Utils_canonicalize, ContractAddress>(account); - if (!_balances.member(disclose(id)) || !_balances.lookup(id).member(disclose(account))) { + if (!_balances.member(disclose(id)) || !_balances.lookup(id).member(disclose(canonAcct))) { return 0; } - return _balances.lookup(id).lookup(disclose(account)); + return _balances.lookup(id).lookup(disclose(canonAcct)); } /** * @description Enables or disables approval for `operator` to manage all of the caller's assets. * - * @circuitInfo k=10, rows=404 + * In the case of an external (non-contract) caller, the caller's identity is derived from the `wit_MultiTokenSK` + * witness as `persistentHash(secretKey)`. + * + * @circuitInfo k=13, rows=2944 * * Requirements: * * - Contract is initialized. - * - `operator` is not the zero address. + * - `operator` is not zero. * - * @param {Either} operator - The ZswapCoinPublicKey or ContractAddress - * whose approval is set for the caller's assets. + * @param {Either, ContractAddress>} operator - The account whose approval is set + * for the caller's assets. * @param {Boolean} approved - The boolean value determining if the operator may or may not handle the * caller's assets. * @return {[]} - Empty tuple. */ export circuit setApprovalForAll( - operator: Either, + operator: Either, ContractAddress>, approved: Boolean ): [] { Initializable_assertInitialized(); - - // TODO: Contract-to-contract calls not yet supported. - const caller = left(ownPublicKey()); + const caller = left, ContractAddress>(_computeAccountId()); _setApprovalForAll(caller, operator, approved); } /** - * @description Queries if `operator` is an authorized operator for `owner`. + * @description Queries if `operator` is an authorized operator for `account`. * - * @circuitInfo k=10, rows=619 + * @circuitInfo k=11, rows=1343 * * Requirements: * * - Contract is initialized. * - * @param {Either} account - The queried possessor of assets. - * @param {Either} operator - The queried handler of `account`'s assets. + * @param {Either, ContractAddress>} account - The queried possessor of assets. + * @param {Either, ContractAddress>} operator - The queried handler of `account`'s assets. * @return {Boolean} - Whether or not `operator` has permission to handle `account`'s assets. */ export circuit isApprovedForAll( - account: Either, - operator: Either + account: Either, ContractAddress>, + operator: Either, ContractAddress> ): Boolean { Initializable_assertInitialized(); + const canonAcct = Utils_canonicalize, ContractAddress>(account); + const canonOp = Utils_canonicalize, ContractAddress>(operator); - if (!_operatorApprovals.member(disclose(account)) || - !_operatorApprovals.lookup(account).member(disclose(operator))) { + if (!_operatorApprovals.member(disclose(canonAcct)) || + !_operatorApprovals.lookup(canonAcct).member(disclose(canonOp))) { return false; } - return _operatorApprovals.lookup(account).lookup(disclose(operator)); + return _operatorApprovals.lookup(canonAcct).lookup(disclose(canonOp)); } /** * @description Transfers ownership of `value` amount of `id` tokens from `fromAddress` to `to`. * The caller must be `fromAddress` or approved to transfer on their behalf. * - * @circuitInfo k=11, rows=1882 + * In the case of an external (non-contract) caller, the caller's identity is derived from the `wit_MultiTokenSK` + * witness as `persistentHash(secretKey)`. * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from * being inadvertently locked in contracts that cannot currently handle token receipt. * - * @extensibility External circuit. Can be used directly by consumers of this module. - * See **Extensibility** documentation for usage patterns. + * @circuitInfo k=13, rows=4926 * * Requirements: * * - Contract is initialized. * - `to` is not a ContractAddress. - * - `to` is not the zero address. - * - `fromAddress` is not the zero address. + * - `to` is not zero. + * - `fromAddress` is not zero. * - Caller must be `fromAddress` or approved via `setApprovalForAll`. * - `fromAddress` must have an `id` balance of at least `value`. * - * @param {Either} fromAddress - The owner from which the transfer originates. - * @param {Either} to - The recipient of the transferred assets. + * @param {Either, ContractAddress>} fromAddress - The owner from which the transfer originates. + * @param {Either, ContractAddress>} to - The recipient of the transferred assets. * @param {Uint<128>} id - The unique identifier of the asset type. * @param {Uint<128>} value - The quantity of `id` tokens to transfer. * @return {[]} - Empty tuple. */ export circuit transferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { - assert(!Utils_isContractAddress(to), "MultiToken: unsafe transfer"); + const isContractAddr = !to.is_left; + assert(!isContractAddr, "MultiToken: unsafe transfer"); _unsafeTransferFrom(fromAddress, to, id, value); } @@ -258,80 +322,82 @@ module MultiToken { * Does not impose restrictions on the caller, making it suitable for composition * in higher-level contract logic. * - * @circuitInfo k=11, rows=1487 - * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from * being inadvertently locked in contracts that cannot currently handle token receipt. * + * @circuitInfo k=12, rows=2450 + * * Requirements: * * - Contract is initialized. * - `to` is not a ContractAddress. - * - `to` is not the zero address. - * - `fromAddress` is not the zero address. + * - `to` is not zero. + * - `fromAddress` is not zero. * - `fromAddress` must have an `id` balance of at least `value`. * - * @param {Either} fromAddress - The owner from which the transfer originates. - * @param {Either} to - The recipient of the transferred assets. + * @param {Either, ContractAddress>} fromAddress - The owner from which the transfer originates. + * @param {Either, ContractAddress>} to - The recipient of the transferred assets. * @param {Uint<128>} id - The unique identifier of the asset type. * @param {Uint<128>} value - The quantity of `id` tokens to transfer. * @return {[]} - Empty tuple. */ export circuit _transfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { - assert(!Utils_isContractAddress(to), "MultiToken: unsafe transfer"); + const isContractAddr = !to.is_left; + assert(!isContractAddr, "MultiToken: unsafe transfer"); _unsafeTransfer(fromAddress, to, id, value); } /** * @description Transfers a value amount of tokens of type id from fromAddress to to. - * This circuit will mint (or burn) if `fromAddress` (or `to`) is the zero address. + * This circuit will mint (or burn) if `fromAddress` (or `to`) is zero. * - * @circuitInfo k=11, rows=1482 + * @notice Both `fromAddress` and `to` are canonicalized internally before use as map keys. * * Requirements: * * - Contract is initialized. * - If `fromAddress` is not zero, the balance of `id` of `fromAddress` must be >= `value`. * - * @param {Either} fromAddress - The origin of the transfer. - * @param {Either} to - The destination of the transfer. + * @param {Either, ContractAddress>} fromAddress - The origin of the transfer. + * @param {Either, ContractAddress>} to - The destination of the transfer. * @param {Uint<128>} id - The unique identifier of the asset type. * @param {Uint<128>} value - The quantity of `id` tokens to transfer. * @return {[]} - Empty tuple. */ - circuit _update(fromAddress: Either, - to: Either, + circuit _update(fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { Initializable_assertInitialized(); + const canonFrom = Utils_canonicalize, ContractAddress>(fromAddress); + const canonTo = Utils_canonicalize, ContractAddress>(to); - if (!Utils_isKeyOrAddressZero(disclose(fromAddress))) { - const fromBalance = balanceOf(fromAddress, id); + if (!_isTargetZero(disclose(canonFrom))) { + const fromBalance = balanceOf(canonFrom, id); assert(fromBalance >= value, "MultiToken: insufficient balance"); - // overflow not possible const newBalance = fromBalance - value; - _balances.lookup(id).insert(disclose(fromAddress), disclose(newBalance)); + _balances.lookup(id).insert(disclose(canonFrom), disclose(newBalance)); } - if (!Utils_isKeyOrAddressZero(disclose(to))) { - // id not initialized + if (!_isTargetZero(disclose(canonTo))) { if (!_balances.member(disclose(id))) { _balances.insert( disclose(id), - default, Uint<128>>> + default, ContractAddress>, Uint<128>>> ); - _balances.lookup(id).insert(disclose(to), disclose(value as Uint<128>)); + _balances.lookup(id).insert(disclose(canonTo), disclose(value as Uint<128>)); } else { - const toBalance = balanceOf(to, id), MAX_UINT128 = 340282366920938463463374607431768211455; + const toBalance = balanceOf(canonTo, id); + const MAX_UINT128 = 340282366920938463463374607431768211455; assert(MAX_UINT128 - toBalance >= value, "MultiToken: arithmetic overflow"); - _balances.lookup(id).insert(disclose(to), disclose(toBalance + value as Uint<128>)); + _balances.lookup(id).insert(disclose(canonTo), disclose(toBalance + value as Uint<128>)); } } } @@ -340,42 +406,44 @@ module MultiToken { * @description Unsafe variant of `transferFrom` which allows transfers to contract addresses. * The caller must be `fromAddress` or approved to transfer on their behalf. * - * @circuitInfo k=11, rows=1881 + * In the case of an external (non-contract) caller, the caller's identity is derived from the `wit_MultiTokenSK` + * witness as `persistentHash(secretKey)`. * * @warning Transfers to contract addresses are considered unsafe because contract-to-contract * calls are not currently supported. Tokens sent to a contract address may become irretrievable. * Once contract-to-contract calls are supported, this circuit may be deprecated. * + * @circuitInfo k=13, rows=4925 + * * Requirements: * * - Contract is initialized. - * - `to` is not the zero address. - * - `fromAddress` is not the zero address. + * - `to` is not zero. + * - `fromAddress` is not zero. * - Caller must be `fromAddress` or approved via `setApprovalForAll`. * - `fromAddress` must have an `id` balance of at least `value`. * - * @param {Either} fromAddress - The owner from which the transfer originates. - * @param {Either} to - The recipient of the transferred assets. + * @param {Either, ContractAddress>} fromAddress - The owner from which the transfer originates. + * @param {Either, ContractAddress>} to - The recipient of the transferred assets. * @param {Uint<128>} id - The unique identifier of the asset type. * @param {Uint<128>} value - The quantity of `id` tokens to transfer. * @return {[]} - Empty tuple. */ export circuit _unsafeTransferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { Initializable_assertInitialized(); - // TODO: Contract-to-contract calls not yet supported. - // Once available, handle ContractAddress recipients here. - const caller = left(ownPublicKey()); - if (disclose(fromAddress) != caller) { - assert(isApprovedForAll(fromAddress, caller), "MultiToken: unauthorized operator"); + const caller = left, ContractAddress>(_computeAccountId()); + const canonFrom = Utils_canonicalize, ContractAddress>(fromAddress); + if (disclose(canonFrom) != disclose(caller)) { + assert(isApprovedForAll(canonFrom, caller), "MultiToken: unauthorized operator"); } - _unsafeTransfer(fromAddress, to, id, value); + _unsafeTransfer(canonFrom, to, id, value); } /** @@ -383,35 +451,35 @@ module MultiToken { * Does not impose restrictions on the caller, making it suitable as a low-level * building block for advanced contract logic. * - * @circuitInfo k=11, rows=1486 - * * @warning Transfers to contract addresses are considered unsafe because contract-to-contract * calls are not currently supported. Tokens sent to a contract address may become irretrievable. * Once contract-to-contract calls are supported, this circuit may be deprecated. * + * @circuitInfo k=12, rows=2449 + * * Requirements: * * - Contract is initialized. - * - `fromAddress` is not the zero address. - * - `to` is not the zero address. + * - `fromAddress` is not zero. + * - `to` is not zero. * - `fromAddress` must have an `id` balance of at least `value`. * - * @param {Either} fromAddress - The owner from which the transfer originates. - * @param {Either} to - The recipient of the transferred assets. + * @param {Either, ContractAddress>} fromAddress - The owner from which the transfer originates. + * @param {Either, ContractAddress>} to - The recipient of the transferred assets. * @param {Uint<128>} id - The unique identifier of the asset type. * @param {Uint<128>} value - The quantity of `id` tokens to transfer. * @return {[]} - Empty tuple. */ export circuit _unsafeTransfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { Initializable_assertInitialized(); - assert(!Utils_isKeyOrAddressZero(fromAddress), "MultiToken: invalid sender"); - assert(!Utils_isKeyOrAddressZero(to), "MultiToken: invalid receiver"); + assert(!_isTargetZero(fromAddress), "MultiToken: invalid sender"); + assert(!_isTargetZero(to), "MultiToken: invalid receiver"); _update(fromAddress, to, id, value); } @@ -420,8 +488,6 @@ module MultiToken { * substitution mechanism defined in the MultiToken standard. * See https://eips.ethereum.org/EIPS/eip-1155#metadata. * - * @circuitInfo k=10, rows=39 - * * @notice By this mechanism, any occurrence of the `\{id\}` substring in either the * URI or any of the values in the JSON file at said URI will be replaced by * clients with the token type ID. @@ -431,6 +497,8 @@ module MultiToken { * `https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json` * for token type ID 0x4cce0. * + * @circuitInfo k=6, rows=28 + * * Requirements: * * - Contract is initialized. @@ -440,129 +508,192 @@ module MultiToken { */ export circuit _setURI(newURI: Opaque<"string">): [] { Initializable_assertInitialized(); - _uri = disclose(newURI); } /** - * @description Creates a `value` amount of tokens of type `token_id`, and assigns them to `to`. - * - * @circuitInfo k=10, rows=912 + * @description Creates a `value` amount of tokens of type `id`, and assigns them to `to`. * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from * being inadvertently locked in contracts that cannot currently handle token receipt. * + * @circuitInfo k=11, rows=1531 + * * Requirements: * * - Contract is initialized. - * - `to` is not the zero address. + * - `to` is not zero. * - `to` is not a ContractAddress. * - * @param {Either} to - The recipient of the minted tokens. + * @param {Either, ContractAddress>} to - The recipient of the minted tokens. * @param {Uint<128>} id - The unique identifier for the token type. * @param {Uint<128>} value - The quantity of `id` tokens that are minted to `to`. * @return {[]} - Empty tuple. */ - export circuit _mint(to: Either, + export circuit _mint(to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { - assert(!Utils_isContractAddress(to), "MultiToken: unsafe transfer"); + const isContractAddr = !to.is_left; + assert(!isContractAddr, "MultiToken: unsafe transfer"); _unsafeMint(to, id, value); } /** * @description Unsafe variant of `_mint` which allows transfers to contract addresses. * - * @circuitInfo k=10, rows=911 - * * @warning Transfers to contract addresses are considered unsafe because contract-to-contract * calls are not currently supported. Tokens sent to a contract address may become irretrievable. * Once contract-to-contract calls are supported, this circuit may be deprecated. * + * @circuitInfo k=11, rows=1530 + * * Requirements: * * - Contract is initialized. - * - `to` is not the zero address. + * - `to` is not zero. * - * @param {Either} to - The recipient of the minted tokens. + * @param {Either, ContractAddress>} to - The recipient of the minted tokens. * @param {Uint<128>} id - The unique identifier for the token type. * @param {Uint<128>} value - The quantity of `id` tokens that are minted to `to`. * @return {[]} - Empty tuple. */ export circuit _unsafeMint( - to: Either, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { Initializable_assertInitialized(); - - assert(!Utils_isKeyOrAddressZero(to), "MultiToken: invalid receiver"); - _update(shieldedBurnAddress(), to, id, value); + assert(!_isTargetZero(to), "MultiToken: invalid receiver"); + _update(ZERO(), to, id, value); } /** - * @description Destroys a `value` amount of tokens of type `token_id` from `fromAddress`. + * @description Destroys a `value` amount of tokens of type `id` from `fromAddress`. * - * @circuitInfo k=10, rows=688 + * @circuitInfo k=11, rows=1222 * * Requirements: * * - Contract is initialized. - * - `fromAddress` is not the zero address. + * - `fromAddress` is not zero. * - `fromAddress` must have an `id` balance of at least `value`. * - * @param {Either} fromAddress - The owner whose tokens will be destroyed. + * @param {Either, ContractAddress>} fromAddress - The owner whose tokens will be destroyed. * @param {Uint<128>} id - The unique identifier of the token type. * @param {Uint<128>} value - The quantity of `id` tokens that will be destroyed from `fromAddress`. * @return {[]} - Empty tuple. */ - export circuit _burn(fromAddress: Either, + export circuit _burn(fromAddress: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { Initializable_assertInitialized(); - - assert(!Utils_isKeyOrAddressZero(fromAddress), "MultiToken: invalid sender"); - _update(fromAddress, shieldedBurnAddress(), id, value); + assert(!_isTargetZero(fromAddress), "MultiToken: invalid sender"); + _update(fromAddress, ZERO(), id, value); } /** - * @description Enables or disables approval for `operator` to manage all of the caller's assets. - * - * @circuitInfo k=10, rows=518 + * @description Enables or disables approval for `operator` to manage all of `owner`'s assets. * * @notice This circuit does not check for access permissions but can be useful as a building block * for more complex contract logic. * + * @circuitInfo k=11, rows=1279 + * * Requirements: * * - Contract is initialized. - * - `operator` is not the zero address. + * - `owner` is not zero. + * - `operator` is not zero. * - * @param {Either} owner - The ZswapCoinPublicKey or ContractAddress of the target owner. - * @param {Either} operator - The ZswapCoinPublicKey or ContractAddress whose approval is set for the - * `owner`'s assets. + * @param {Either, ContractAddress>} owner - The account whose assets are managed. + * @param {Either, ContractAddress>} operator - The account whose approval is set for + * the `owner`'s assets. * @param {Boolean} approved - The boolean value determining if the operator may or may not handle the * `owner`'s assets. * @return {[]} - Empty tuple. */ export circuit _setApprovalForAll( - owner: Either, - operator: Either, + owner: Either, ContractAddress>, + operator: Either, ContractAddress>, approved: Boolean ): [] { Initializable_assertInitialized(); + const canonOwner = Utils_canonicalize, ContractAddress>(owner); + assert(!_isTargetZero(canonOwner), "MultiToken: invalid owner"); - assert(!Utils_isKeyOrAddressZero(operator), "MultiToken: invalid operator"); - if (!_operatorApprovals.member(disclose(owner))) { + const canonOp = Utils_canonicalize, ContractAddress>(operator); + assert(!_isTargetZero(canonOp), "MultiToken: invalid operator"); + + if (!_operatorApprovals.member(disclose(canonOwner))) { _operatorApprovals.insert( - disclose(owner), - default, Boolean>> + disclose(canonOwner), + default, ContractAddress>, Boolean>> ); } - _operatorApprovals.lookup(owner).insert(disclose(operator), disclose(approved)); + _operatorApprovals.lookup(canonOwner).insert(disclose(canonOp), disclose(approved)); + } + + /** + * @description Computes the caller's account identifier from the `wit_MultiTokenSK` witness. + * + * ## ID Derivation + * `accountId = persistentHash(secretKey)` + * + * The result is a 32-byte commitment that uniquely identifies the caller. + * + * @returns {Bytes<32>} accountId - The computed account identifier. + */ + circuit _computeAccountId(): Bytes<32> { + return computeAccountId(wit_MultiTokenSK()); + } + + /** + * @description Computes an account identifier without on-chain state, allowing a user to derive + * their identity commitment before submitting it in a token operation. + * This is the off-chain counterpart to {_computeAccountId} and produces an identical result + * given the same inputs. + * + * @warning OpSec: The `secretKey` parameter is a sensitive secret. Mishandling it can + * permanently compromise the security of this system: + * + * - **Never log or persist** the `secretKey` in plaintext — avoid browser devtools, + * application logs, analytics pipelines, or any observable side-channel. + * - **Store offline or in secure enclaves** — hardware security modules (HSMs), + * air-gapped devices, or encrypted vaults are strongly preferred over hot storage. + * - **Use cryptographically secure randomness** — generate keys with `crypto.getRandomValues()` + * or equivalent; weak or predictable keys can be brute-forced to reveal your identity. + * - **Treat key loss as identity loss** — a lost key cannot be recovered. + * - **Avoid calling this circuit in untrusted environments** — executing this in an + * unverified browser extension, compromised runtime, or shared machine may expose + * the key to a malicious observer. + * + * ## ID Derivation + * `accountId = persistentHash(secretKey)` + * + * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + * + * @returns {Bytes<32>} accountId - The computed account identifier. + */ + export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> { + return persistentHash>>([secretKey]); + } + + /** + * @description Returns `true` if `target`'s active branch (as indicated by `is_left`) + * holds the zero value. + * + * @param {Either, ContractAddress>} target - The value to check. + * @returns {Boolean} - `true` if the active branch is zero, `false` otherwise. + */ + circuit _isTargetZero(target: Either, ContractAddress>): Boolean { + if (target.is_left) { + return target.left == default>; + } else { + return target.right == default; + } } } diff --git a/contracts/src/token/test/MultiToken.test.ts b/contracts/src/token/test/MultiToken.test.ts index d8a8a707..63cf9c99 100644 --- a/contracts/src/token/test/MultiToken.test.ts +++ b/contracts/src/token/test/MultiToken.test.ts @@ -1,8 +1,84 @@ +import { + CompactTypeBytes, + CompactTypeVector, + persistentHash, +} from '@midnight-ntwrk/compact-runtime'; import { beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/address.js'; -import type { Maybe } from '../../../artifacts/MockMultiToken/contract/index.js'; // Combined imports +import type { Maybe } from '../../../artifacts/MockMultiToken/contract/index.js'; import { MultiTokenSimulator } from './simulators/MultiTokenSimulator.js'; +// Helpers +const buildAccountIdHash = (sk: Uint8Array): Uint8Array => { + const rt_type = new CompactTypeVector(1, new CompactTypeBytes(32)); + return persistentHash(rt_type, [sk]); +}; + +const zeroBytes = utils.zeroUint8Array(); + +const eitherAccountId = (accountId: Uint8Array) => { + return { + is_left: true, + left: accountId, + right: { bytes: zeroBytes }, + }; +}; + +const eitherContract = (address: string) => { + return { + is_left: false, + left: zeroBytes, + right: utils.encodeToAddress(address), + }; +}; + +const createTestSK = (label: string): Uint8Array => { + const sk = new Uint8Array(32); + const encoded = new TextEncoder().encode(label); + sk.set(encoded.slice(0, 32)); + return sk; +}; + +const makeUser = (label: string) => { + const secretKey = createTestSK(label); + const accountId = buildAccountIdHash(secretKey); + const either = eitherAccountId(accountId); + return { secretKey, accountId, either }; +}; + +const nonCanonicalLeft = (accountId: Uint8Array) => ({ + is_left: true as const, + left: accountId, + right: utils.encodeToAddress('JUNK_DATA'), +}); + +const nonCanonicalRight = ( + address: ReturnType, +) => ({ + is_left: false as const, + left: new Uint8Array(32).fill(1), + right: address, +}); + +// Users +const OWNER = makeUser('OWNER'); +const SPENDER = makeUser('SPENDER'); +const RECIPIENT = makeUser('RECIPIENT'); +const OTHER = makeUser('OTHER'); +const UNAUTHORIZED = makeUser('UNAUTHORIZED'); + +// Contract Addresses +const OWNER_CONTRACT = eitherContract('OWNER_CONTRACT'); +const RECIPIENT_CONTRACT = eitherContract('RECIPIENT_CONTRACT'); + +// Zero Values +const ZERO_ACCOUNT = eitherAccountId(zeroBytes); +const ZERO_CONTRACT = { + is_left: false, + left: zeroBytes, + right: { bytes: zeroBytes }, +}; + // URIs const NO_STRING = ''; const URI = 'https://uri.com/mock_v1'; @@ -18,20 +94,6 @@ const TOKEN_ID: bigint = BigInt(1); const TOKEN_ID2: bigint = BigInt(22); const NONEXISTENT_ID: bigint = BigInt(987654321); -// PKs -const [OWNER, Z_OWNER] = utils.generateEitherPubKeyPair('OWNER'); -const [SPENDER, Z_SPENDER] = utils.generateEitherPubKeyPair('SPENDER'); -const [UNAUTHORIZED] = utils.generateEitherPubKeyPair('UNAUTHORIZED'); -const [ZERO] = utils.generateEitherPubKeyPair(''); -const [, Z_RECIPIENT] = utils.generateEitherPubKeyPair('RECIPIENT'); -const [OTHER, Z_OTHER] = utils.generateEitherPubKeyPair('OTHER'); - -// Encoded contract addresses -const Z_OWNER_CONTRACT = - utils.createEitherTestContractAddress('OWNER_CONTRACT'); -const Z_RECIPIENT_CONTRACT = - utils.createEitherTestContractAddress('RECIPIENT_CONTRACT'); - // Init const initWithURI: Maybe = { is_some: true, @@ -48,10 +110,10 @@ const badInit: Maybe = { value: '', }; -// Helper types +// Types const recipientTypes = [ - ['contract', Z_RECIPIENT_CONTRACT], - ['pubkey', Z_RECIPIENT], + ['contract', RECIPIENT_CONTRACT], + ['accountId', RECIPIENT.either], ] as const; const callerTypes = [ @@ -90,21 +152,20 @@ describe('MultiToken', () => { }); type FailingCircuits = [method: keyof MultiTokenSimulator, args: unknown[]]; - // Circuit calls should fail before the args are used - const transferArgs = [Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT]; + const transferArgs = [OWNER.either, RECIPIENT.either, TOKEN_ID, AMOUNT]; const circuitsToFail: FailingCircuits[] = [ ['uri', [TOKEN_ID]], - ['balanceOf', [Z_OWNER, TOKEN_ID]], - ['setApprovalForAll', [Z_OWNER, true]], - ['isApprovedForAll', [Z_OWNER, Z_SPENDER]], + ['balanceOf', [OWNER.either, TOKEN_ID]], + ['setApprovalForAll', [OWNER.either, true]], + ['isApprovedForAll', [OWNER.either, SPENDER.either]], ['transferFrom', transferArgs], ['_unsafeTransferFrom', transferArgs], ['_transfer', transferArgs], ['_unsafeTransfer', transferArgs], ['_setURI', [URI]], - ['_mint', [Z_OWNER, TOKEN_ID, AMOUNT]], - ['_burn', [Z_OWNER, TOKEN_ID, AMOUNT]], - ['_setApprovalForAll', [Z_OWNER, Z_SPENDER, true]], + ['_mint', [OWNER.either, TOKEN_ID, AMOUNT]], + ['_burn', [OWNER.either, TOKEN_ID, AMOUNT]], + ['_setApprovalForAll', [OWNER.either, SPENDER.either, true]], ]; it.each(circuitsToFail)('%s should fail', (circuitName, args) => { @@ -113,14 +174,11 @@ describe('MultiToken', () => { }).toThrow('Initializable: contract not initialized'); }); - // Though, there is no restriction on initializing post deployment, - // contracts should NOT be set up this way. - // Always use the constructor to initialize the state. it('should allow initialization post deployment', () => { token.initialize(URI); expect(() => { - token.balanceOf(Z_OWNER, TOKEN_ID); + token.balanceOf(OWNER.either, TOKEN_ID); }).not.toThrow(); }); }); @@ -130,10 +188,31 @@ describe('MultiToken', () => { token = new MultiTokenSimulator(initWithURI); }); + describe('computeAccountId', () => { + const users = [OWNER, SPENDER, RECIPIENT, UNAUTHORIZED]; + + it('should match the test helper derivation', () => { + for (const user of users) { + expect(token.computeAccountId(user.secretKey)).toEqual( + user.accountId, + ); + } + }); + + it('should produce distinct identifiers for distinct keys', () => { + const ids = users.map((u) => token.computeAccountId(u.secretKey)); + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + expect(ids[i]).not.toEqual(ids[j]); + } + } + }); + }); + describe('balanceOf', () => { const ownerTypes = [ - ['contract', Z_OWNER_CONTRACT], - ['pubkey', Z_OWNER], + ['contract', OWNER_CONTRACT], + ['accountId', OWNER.either], ] as const; describe.each(ownerTypes)('when the owner is a %s', (_, owner) => { @@ -162,469 +241,665 @@ describe('MultiToken', () => { expect(token.balanceOf(owner, MAX_ID)).toEqual(AMOUNT); }); }); + + it('should return correct balance with non-canonical lookup (left)', () => { + token._unsafeMint(OWNER.either, TOKEN_ID, AMOUNT); + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + + expect(token.balanceOf(nonCanonical, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should return correct balance with non-canonical lookup (right)', () => { + token._unsafeMint(OWNER_CONTRACT, TOKEN_ID, AMOUNT); + const nonCanonical = nonCanonicalRight(OWNER_CONTRACT.right); + + expect(token.balanceOf(nonCanonical, TOKEN_ID)).toEqual(AMOUNT); + }); }); describe('isApprovedForAll', () => { it('should return false when not set', () => { - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); }); it('should handle approving owner as operator', () => { - token.as(OWNER).setApprovalForAll(Z_OWNER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_OWNER)).toBe(true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(OWNER.either, true); + expect(token.isApprovedForAll(OWNER.either, OWNER.either)).toBe(true); }); it('should handle multiple approvals of same operator', () => { - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, true); + token.setApprovalForAll(SPENDER.either, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe(true); }); it('should handle revoking non-existent approval', () => { - token.as(OWNER).setApprovalForAll(Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); + }); + + it('should return correct result with non-canonical owner lookup', () => { + token._setApprovalForAll(OWNER.either, SPENDER.either, true); + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + + expect(token.isApprovedForAll(nonCanonical, SPENDER.either)).toBe(true); + }); + + it('should return correct result with non-canonical operator lookup', () => { + token._setApprovalForAll(OWNER.either, SPENDER.either, true); + const nonCanonical = nonCanonicalLeft(SPENDER.accountId); + + expect(token.isApprovedForAll(OWNER.either, nonCanonical)).toBe(true); }); }); describe('setApprovalForAll', () => { it('should return false when set to false', () => { - token.as(OWNER).setApprovalForAll(Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); }); it('should fail when attempting to approve zero address as an operator', () => { + token.privateState.injectSecretKey(OWNER.secretKey); expect(() => { - token.as(OWNER).setApprovalForAll(utils.ZERO_KEY, true); + token.setApprovalForAll(ZERO_ACCOUNT, true); }).toThrow('MultiToken: invalid operator'); }); describe('when spender is approved as an operator', () => { beforeEach(() => { - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, true); }); it('should return true when set to true', () => { - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + true, + ); }); it('should unset → set → unset operator', () => { - token.setApprovalForAll(Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token.setApprovalForAll(SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); - token.setApprovalForAll(Z_SPENDER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + token.setApprovalForAll(SPENDER.either, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + true, + ); - token.setApprovalForAll(Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token.setApprovalForAll(SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); }); }); }); describe('transferFrom', () => { beforeEach(() => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT); + token._mint(OWNER.either, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); describe.each(callerTypes)('when the caller is the %s', (_, caller) => { beforeEach(() => { if (caller === SPENDER) { - token._setApprovalForAll(Z_OWNER, Z_SPENDER, true); + token._setApprovalForAll(OWNER.either, SPENDER.either, true); } + token.privateState.injectSecretKey(caller.secretKey); }); it('should transfer whole', () => { - token.as(caller).transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); + token.transferFrom(OWNER.either, RECIPIENT.either, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(0n); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); }); it('should transfer partial', () => { const partialAmt = AMOUNT - 1n; - token - .as(caller) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, partialAmt); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + partialAmt, + ); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual( + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( AMOUNT - partialAmt, ); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(partialAmt); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual( + partialAmt, + ); }); it('should allow transfer of 0 tokens', () => { - token.as(caller).transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, 0n); + token.transferFrom(OWNER.either, RECIPIENT.either, TOKEN_ID, 0n); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); it('should handle self-transfer', () => { - token.as(caller).transferFrom(Z_OWNER, Z_OWNER, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); + token.transferFrom(OWNER.either, OWNER.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); }); it('should handle MAX_UINT128 transfer amount', () => { - // Mint rest of tokens to == MAX_UINT128 - token._mint(Z_OWNER, TOKEN_ID, MAX_UINT128 - AMOUNT); + token._mint(OWNER.either, TOKEN_ID, MAX_UINT128 - AMOUNT); - token - .as(caller) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, MAX_UINT128); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(MAX_UINT128); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + MAX_UINT128, + ); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual( + MAX_UINT128, + ); }); it('should handle rapid state changes', () => { - // Approve -> Transfer -> Revoke -> Approve - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, true); - token - .as(SPENDER) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT); + token.privateState.injectSecretKey(SPENDER.secretKey); + token.transferFrom(OWNER.either, RECIPIENT.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); - token.as(OWNER).setApprovalForAll(Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + token.setApprovalForAll(SPENDER.either, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + true, + ); }); it('should fail with insufficient balance', () => { expect(() => { - token - .as(caller) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT + 1n); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + AMOUNT + 1n, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail with nonexistent id', () => { expect(() => { - token - .as(caller) - .transferFrom(Z_OWNER, Z_RECIPIENT, NONEXISTENT_ID, AMOUNT); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + NONEXISTENT_ID, + AMOUNT, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail with transfer from zero', () => { expect(() => { - token - .as(caller) - .transferFrom(utils.ZERO_KEY, Z_RECIPIENT, TOKEN_ID, AMOUNT); + token.transferFrom( + ZERO_ACCOUNT, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: unauthorized operator'); }); - it('should fail with transfer to zero (pk)', () => { + it('should fail with transfer to zero (id)', () => { expect(() => { - token - .as(caller) - .transferFrom(Z_OWNER, utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token.transferFrom(OWNER.either, ZERO_ACCOUNT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); it('should fail with transfer to zero (contract)', () => { expect(() => { - token - .as(caller) - .transferFrom(Z_OWNER, utils.ZERO_ADDRESS, TOKEN_ID, AMOUNT); + token.transferFrom(OWNER.either, ZERO_CONTRACT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: unsafe transfer'); }); it('should fail when transferring to a contract address', () => { expect(() => { - token - .as(caller) - .transferFrom(Z_OWNER, Z_RECIPIENT_CONTRACT, TOKEN_ID, AMOUNT); + token.transferFrom( + OWNER.either, + RECIPIENT_CONTRACT, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: unsafe transfer'); }); }); it('should handle concurrent operations on same token ID', () => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT * 2n); + token._mint(OWNER.either, TOKEN_ID, AMOUNT * 2n); - // Set up two spenders - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); - token.as(OWNER).setApprovalForAll(Z_OTHER, true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, true); + token.setApprovalForAll(OTHER.either, true); // First spender transfers half - token.as(SPENDER).transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT); + token.privateState.injectSecretKey(SPENDER.secretKey); + token.transferFrom(OWNER.either, RECIPIENT.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); // Second spender transfers remaining - token.as(OTHER).transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT * 2n); + token.privateState.injectSecretKey(OTHER.secretKey); + token.transferFrom(OWNER.either, RECIPIENT.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual( + AMOUNT * 2n, + ); + }); + + it('should handle non-canonical fromAddress (id)', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + token.transferFrom(nonCanonical, RECIPIENT.either, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should handle non-canonical fromAddress (contract address)', () => { + token._unsafeMint(OWNER_CONTRACT, TOKEN_ID, AMOUNT); + token._setApprovalForAll(OWNER_CONTRACT, OWNER.either, true); + + token.privateState.injectSecretKey(OWNER.secretKey); + + const nonCanonical = nonCanonicalRight(OWNER_CONTRACT.right); + token.transferFrom(nonCanonical, RECIPIENT.either, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); }); describe('when the caller is unauthorized', () => { + beforeEach(() => { + token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); + }); + it('should fail when transfer whole', () => { expect(() => { - token - .as(UNAUTHORIZED) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail when transfer partial', () => { expect(() => { const partialAmt = AMOUNT - 1n; - token - .as(UNAUTHORIZED) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, partialAmt); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + partialAmt, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail when transfer zero', () => { expect(() => { - token - .as(UNAUTHORIZED) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, 0n); + token.transferFrom(OWNER.either, RECIPIENT.either, TOKEN_ID, 0n); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail with insufficient balance', () => { expect(() => { - token - .as(UNAUTHORIZED) - .transferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT + 1n); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + AMOUNT + 1n, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail with nonexistent id', () => { expect(() => { - token - .as(UNAUTHORIZED) - .transferFrom(Z_OWNER, Z_RECIPIENT, NONEXISTENT_ID, AMOUNT); + token.transferFrom( + OWNER.either, + RECIPIENT.either, + NONEXISTENT_ID, + AMOUNT, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail with transfer from zero', () => { expect(() => { - token - .as(ZERO) - .transferFrom(utils.ZERO_KEY, Z_RECIPIENT, TOKEN_ID, AMOUNT); - }).toThrow('MultiToken: invalid sender'); + token.transferFrom( + ZERO_ACCOUNT, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); + }).toThrow('MultiToken: unauthorized operator'); }); }); }); describe('_unsafeTransferFrom', () => { beforeEach(() => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT); + token._mint(OWNER.either, TOKEN_ID, AMOUNT); }); describe.each(callerTypes)('when the caller is the %s', (_, caller) => { beforeEach(() => { if (caller === SPENDER) { - token._setApprovalForAll(Z_OWNER, Z_SPENDER, true); + token._setApprovalForAll(OWNER.either, SPENDER.either, true); } + token.privateState.injectSecretKey(caller.secretKey); }); describe.each( recipientTypes, )('when the recipient is a %s', (_, recipient) => { it('should transfer whole', () => { - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, AMOUNT); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT, + ); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); }); it('should transfer partial', () => { const partialAmt = AMOUNT - 1n; - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, partialAmt); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + partialAmt, + ); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual( + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( AMOUNT - partialAmt, ); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(partialAmt); }); it('should allow transfer of 0 tokens', () => { - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, 0n); + token._unsafeTransferFrom(OWNER.either, recipient, TOKEN_ID, 0n); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(0n); }); it('should handle self-transfer', () => { - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, Z_OWNER, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); + token._unsafeTransferFrom( + OWNER.either, + OWNER.either, + TOKEN_ID, + AMOUNT, + ); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); }); it('should handle MAX_UINT128 transfer amount', () => { - // Mint rest of tokens to == MAX_UINT128 - token._mint(Z_OWNER, TOKEN_ID, MAX_UINT128 - AMOUNT); + token._mint(OWNER.either, TOKEN_ID, MAX_UINT128 - AMOUNT); - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, MAX_UINT128); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + MAX_UINT128, + ); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(MAX_UINT128); }); it('should handle rapid state changes', () => { - // Approve -> Transfer -> Revoke -> Approve - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, true); - token - .as(OWNER) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, AMOUNT); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT, + ); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); - token.as(OWNER).setApprovalForAll(Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token.setApprovalForAll(SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + token.setApprovalForAll(SPENDER.either, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + true, + ); }); it('should fail with insufficient balance', () => { expect(() => { - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, AMOUNT + 1n); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT + 1n, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail with nonexistent id', () => { expect(() => { - token - .as(caller) - ._unsafeTransferFrom( - Z_OWNER, - recipient, - NONEXISTENT_ID, - AMOUNT, - ); + token._unsafeTransferFrom( + OWNER.either, + recipient, + NONEXISTENT_ID, + AMOUNT, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail with transfer from zero', () => { expect(() => { - token - .as(caller) - ._unsafeTransferFrom( - utils.ZERO_KEY, - recipient, - TOKEN_ID, - AMOUNT, - ); + token._unsafeTransferFrom( + ZERO_ACCOUNT, + recipient, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: unauthorized operator'); }); }); - it('should fail with transfer to zero (pk)', () => { + it('should fail with transfer to zero (id)', () => { expect(() => { - token - .as(caller) - ._unsafeTransferFrom(Z_OWNER, utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token._unsafeTransferFrom( + OWNER.either, + ZERO_ACCOUNT, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: invalid receiver'); }); it('should fail with transfer to zero (contract)', () => { expect(() => { - token - .as(caller) - ._unsafeTransferFrom( - Z_OWNER, - utils.ZERO_ADDRESS, - TOKEN_ID, - AMOUNT, - ); + token._unsafeTransferFrom( + OWNER.either, + ZERO_CONTRACT, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: invalid receiver'); }); }); it('should handle concurrent operations on same token ID', () => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT * 2n); + token._mint(OWNER.either, TOKEN_ID, AMOUNT * 2n); - // Set up two spenders - token.as(OWNER).setApprovalForAll(Z_SPENDER, true); - token.as(OWNER).setApprovalForAll(Z_OTHER, true); + token.privateState.injectSecretKey(OWNER.secretKey); + token.setApprovalForAll(SPENDER.either, true); + token.setApprovalForAll(OTHER.either, true); // First spender transfers half - token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT); + token.privateState.injectSecretKey(SPENDER.secretKey); + token._unsafeTransferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); // Second spender transfers remaining - token - .as(OTHER) - ._unsafeTransferFrom(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT * 2n); + token.privateState.injectSecretKey(OTHER.secretKey); + token._unsafeTransferFrom( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual( + AMOUNT * 2n, + ); + }); + + it('should handle non-canonical fromAddress (id)', () => { + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + + token.privateState.injectSecretKey(OWNER.secretKey); + token._unsafeTransferFrom( + nonCanonical, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should handle non-canonical fromAddress (contract address)', () => { + // Mint to contract address to test the transfer of non-canonical `fromAddress` + token._unsafeMint(OWNER_CONTRACT, TOKEN_ID, AMOUNT); + // Approve owner (id) to move OWNER_CONTRACT's token + token._setApprovalForAll(OWNER_CONTRACT, OWNER.either, true); + + token.privateState.injectSecretKey(OWNER.secretKey); + const nonCanonical = nonCanonicalRight(OWNER_CONTRACT.right); + token._unsafeTransferFrom( + nonCanonical, + RECIPIENT.either, + TOKEN_ID, + AMOUNT, + ); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should canonicalize recipient (id)', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + + const nonCanonical = nonCanonicalLeft(RECIPIENT.accountId); + token._unsafeTransferFrom(OWNER.either, nonCanonical, TOKEN_ID, AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should canonicalize recipient (contract address)', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + + const nonCanonical = nonCanonicalRight(RECIPIENT_CONTRACT.right); + token._unsafeTransferFrom(OWNER.either, nonCanonical, TOKEN_ID, AMOUNT); + expect(token.balanceOf(RECIPIENT_CONTRACT, TOKEN_ID)).toEqual(AMOUNT); }); describe('when the caller is unauthorized', () => { + beforeEach(() => { + token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); + }); + describe.each( recipientTypes, )('when recipient is %s', (_, recipient) => { it('should fail when transfer whole', () => { expect(() => { - token - .as(UNAUTHORIZED) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, AMOUNT); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail when transfer partial', () => { expect(() => { const partialAmt = AMOUNT - 1n; - token - .as(UNAUTHORIZED) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, partialAmt); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + partialAmt, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail when transfer zero', () => { expect(() => { - token - .as(UNAUTHORIZED) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, 0n); + token._unsafeTransferFrom(OWNER.either, recipient, TOKEN_ID, 0n); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail with insufficient balance', () => { expect(() => { - token - .as(UNAUTHORIZED) - ._unsafeTransferFrom(Z_OWNER, recipient, TOKEN_ID, AMOUNT + 1n); + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT + 1n, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail with nonexistent id', () => { expect(() => { - token - .as(UNAUTHORIZED) - ._unsafeTransferFrom( - Z_OWNER, - recipient, - NONEXISTENT_ID, - AMOUNT, - ); + token._unsafeTransferFrom( + OWNER.either, + recipient, + NONEXISTENT_ID, + AMOUNT, + ); }).toThrow('MultiToken: unauthorized operator'); }); it('should fail with transfer from zero', () => { + // With witness-based identity, the caller is H(sk) which is + // always non-zero. Transferring from ZERO_ACCOUNT means + // canonFrom != caller → isApprovedForAll(ZERO, caller) → false + // → "unauthorized operator" expect(() => { - token - .as(ZERO) - ._unsafeTransferFrom( - utils.ZERO_KEY, - recipient, - TOKEN_ID, - AMOUNT, - ); - }).toThrow('MultiToken: invalid sender'); + token._unsafeTransferFrom( + ZERO_ACCOUNT, + recipient, + TOKEN_ID, + AMOUNT, + ); + }).toThrow('MultiToken: unauthorized operator'); }); }); }); @@ -632,139 +907,208 @@ describe('MultiToken', () => { describe('_transfer', () => { beforeEach(() => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT); + token._mint(OWNER.either, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); it('should transfer whole', () => { - token._transfer(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT); + token._transfer(OWNER.either, RECIPIENT.either, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(0n); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); }); it('should transfer partial', () => { const partialAmt = AMOUNT - 1n; - token._transfer(Z_OWNER, Z_RECIPIENT, TOKEN_ID, partialAmt); + token._transfer(OWNER.either, RECIPIENT.either, TOKEN_ID, partialAmt); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT - partialAmt); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(partialAmt); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( + AMOUNT - partialAmt, + ); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(partialAmt); }); it('should allow transfer of 0 tokens', () => { - token._transfer(Z_OWNER, Z_RECIPIENT, TOKEN_ID, 0n); + token._transfer(OWNER.either, RECIPIENT.either, TOKEN_ID, 0n); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); - it('should fail with unsufficient balance', () => { + it('should fail with insufficient balance', () => { expect(() => { - token._transfer(Z_OWNER, Z_RECIPIENT, TOKEN_ID, AMOUNT + 1n); + token._transfer( + OWNER.either, + RECIPIENT.either, + TOKEN_ID, + AMOUNT + 1n, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail with nonexistent id', () => { expect(() => { - token._transfer(Z_OWNER, Z_RECIPIENT, NONEXISTENT_ID, AMOUNT); + token._transfer( + OWNER.either, + RECIPIENT.either, + NONEXISTENT_ID, + AMOUNT, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail when transfer from 0', () => { expect(() => { - token._transfer(utils.ZERO_KEY, Z_RECIPIENT, TOKEN_ID, AMOUNT); + token._transfer(ZERO_ACCOUNT, RECIPIENT.either, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid sender'); }); it('should fail when transfer to 0', () => { expect(() => { - token._transfer(Z_OWNER, utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token._transfer(OWNER.either, ZERO_ACCOUNT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); it('should fail when transfer to contract address', () => { expect(() => { - token._transfer(Z_OWNER, Z_RECIPIENT_CONTRACT, TOKEN_ID, AMOUNT); + token._transfer(OWNER.either, RECIPIENT_CONTRACT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: unsafe transfer'); }); + + it('should handle non-canonical fromAddress (id)', () => { + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + + token._transfer(nonCanonical, RECIPIENT.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should handle non-canonical fromAddress (contract address)', () => { + token._unsafeMint(OWNER_CONTRACT, TOKEN_ID, AMOUNT); + + const nonCanonical = nonCanonicalRight(OWNER_CONTRACT.right); + token._transfer(nonCanonical, RECIPIENT.either, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(OWNER_CONTRACT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); }); describe('_unsafeTransfer', () => { beforeEach(() => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT); + token._mint(OWNER.either, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); describe.each( recipientTypes, )('when the recipient is a %s', (_, recipient) => { it('should transfer whole', () => { - token._unsafeTransfer(Z_OWNER, recipient, TOKEN_ID, AMOUNT); + token._unsafeTransfer(OWNER.either, recipient, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); }); it('should transfer partial', () => { const partialAmt = AMOUNT - 1n; - token._unsafeTransfer(Z_OWNER, recipient, TOKEN_ID, partialAmt); + token._unsafeTransfer(OWNER.either, recipient, TOKEN_ID, partialAmt); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual( + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( AMOUNT - partialAmt, ); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(partialAmt); }); it('should allow transfer of 0 tokens', () => { - token._unsafeTransfer(Z_OWNER, recipient, TOKEN_ID, 0n); + token._unsafeTransfer(OWNER.either, recipient, TOKEN_ID, 0n); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); expect(token.balanceOf(recipient, TOKEN_ID)).toEqual(0n); }); - it('should fail with unsufficient balance', () => { + it('should fail with insufficient balance', () => { expect(() => { - token._unsafeTransfer(Z_OWNER, recipient, TOKEN_ID, AMOUNT + 1n); + token._unsafeTransfer( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT + 1n, + ); }).toThrow('MultiToken: insufficient balance'); }); it('should fail with nonexistent id', () => { expect(() => { - token._unsafeTransfer(Z_OWNER, recipient, NONEXISTENT_ID, AMOUNT); + token._unsafeTransfer( + OWNER.either, + recipient, + NONEXISTENT_ID, + AMOUNT, + ); }).toThrow('MultiToken: insufficient balance'); }); - it('should fail when transfer from 0 (pk)', () => { + it('should fail when transfer from 0 (id)', () => { expect(() => { - token._unsafeTransfer(utils.ZERO_KEY, recipient, TOKEN_ID, AMOUNT); + token._unsafeTransfer(ZERO_ACCOUNT, recipient, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid sender'); }); it('should fail when transfer from 0 (contract address)', () => { expect(() => { - token._unsafeTransfer( - utils.ZERO_ADDRESS, - recipient, - TOKEN_ID, - AMOUNT, - ); + token._unsafeTransfer(ZERO_CONTRACT, recipient, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid sender'); }); }); - it('should fail when transfer to 0 (pk)', () => { + it('should handle non-canonical fromAddress (id)', () => { + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + token._unsafeTransfer(nonCanonical, RECIPIENT.either, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should handle non-canonical fromAddress (contract address)', () => { + // Mint to contract address to test the transfer of non-canonical `fromAddress` + token._unsafeMint(OWNER_CONTRACT, TOKEN_ID, AMOUNT); + + const nonCanonical = nonCanonicalRight(OWNER_CONTRACT.right); + token._unsafeTransfer(nonCanonical, RECIPIENT.either, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(OWNER_CONTRACT, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should handle non-canonical to (id)', () => { + const nonCanonical = nonCanonicalLeft(RECIPIENT.accountId); + token._unsafeTransfer(OWNER.either, nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should handle non-canonical to (contract address)', () => { + const nonCanonical = nonCanonicalRight(RECIPIENT_CONTRACT.right); + token._unsafeTransfer(OWNER.either, nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(RECIPIENT_CONTRACT, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should fail when transfer to 0 (id)', () => { expect(() => { - token._unsafeTransfer(Z_OWNER, utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token._unsafeTransfer(OWNER.either, ZERO_ACCOUNT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); it('should fail when transfer to 0 (contract address)', () => { expect(() => { - token._unsafeTransfer(Z_OWNER, utils.ZERO_ADDRESS, TOKEN_ID, AMOUNT); + token._unsafeTransfer(OWNER.either, ZERO_CONTRACT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); }); @@ -803,43 +1147,50 @@ describe('MultiToken', () => { describe('_mint', () => { it('should update balance when minting', () => { - token._mint(Z_RECIPIENT, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(AMOUNT); + token._mint(RECIPIENT.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); }); it('should update balance with multiple mints', () => { for (let i = 0; i < 3; i++) { - token._mint(Z_RECIPIENT, TOKEN_ID, 1n); + token._mint(RECIPIENT.either, TOKEN_ID, 1n); } - expect(token.balanceOf(Z_RECIPIENT, TOKEN_ID)).toEqual(3n); + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(3n); }); - it('should fail when overflowing uin128', () => { - token._mint(Z_RECIPIENT, TOKEN_ID, MAX_UINT128); + it('should fail when overflowing uint128', () => { + token._mint(RECIPIENT.either, TOKEN_ID, MAX_UINT128); expect(() => { - token._mint(Z_RECIPIENT, TOKEN_ID, 1n); + token._mint(RECIPIENT.either, TOKEN_ID, 1n); }).toThrow('MultiToken: arithmetic overflow'); }); - it('should fail when minting to zero address (pk)', () => { + it('should fail when minting to zero address (id)', () => { expect(() => { - token._mint(utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token._mint(ZERO_ACCOUNT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); it('should fail when minting to zero address (contract)', () => { expect(() => { - token._mint(utils.ZERO_ADDRESS, TOKEN_ID, AMOUNT); + token._mint(ZERO_CONTRACT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: unsafe transfer'); }); it('should fail when minting to a contract address', () => { expect(() => { - token._mint(Z_RECIPIENT_CONTRACT, TOKEN_ID, AMOUNT); + token._mint(RECIPIENT_CONTRACT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: unsafe transfer'); }); + + it('should canonicalize recipient', () => { + const nonCanonical = nonCanonicalLeft(RECIPIENT.accountId); + token._mint(nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); }); describe('_unsafeMint', () => { @@ -869,84 +1220,162 @@ describe('MultiToken', () => { }); }); - it('should fail when minting to zero address (pk)', () => { + it('should fail when minting to zero address (id)', () => { expect(() => { - token._unsafeMint(utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token._unsafeMint(ZERO_ACCOUNT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); it('should fail when minting to zero address (contract)', () => { expect(() => { - token._unsafeMint(utils.ZERO_ADDRESS, TOKEN_ID, AMOUNT); + token._unsafeMint(ZERO_CONTRACT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid receiver'); }); + + it('should canonicalize recipient', () => { + const nonCanonical = nonCanonicalLeft(RECIPIENT.accountId); + token._unsafeMint(nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should canonicalize contract address recipient', () => { + const nonCanonical = nonCanonicalRight(RECIPIENT_CONTRACT.right); + token._unsafeMint(nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(RECIPIENT_CONTRACT, TOKEN_ID)).toEqual(AMOUNT); + }); }); describe('_burn', () => { beforeEach(() => { - token._mint(Z_OWNER, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT); + token._mint(OWNER.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); }); it('should burn tokens', () => { - token._burn(Z_OWNER, TOKEN_ID, AMOUNT); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(0n); + token._burn(OWNER.either, TOKEN_ID, AMOUNT); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); }); it('should burn partial', () => { const partialAmt = 1n; - token._burn(Z_OWNER, TOKEN_ID, partialAmt); - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT - partialAmt); + token._burn(OWNER.either, TOKEN_ID, partialAmt); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( + AMOUNT - partialAmt, + ); }); it('should update balance with multiple burns', () => { for (let i = 0; i < 3; i++) { - token._burn(Z_OWNER, TOKEN_ID, 1n); + token._burn(OWNER.either, TOKEN_ID, 1n); } - expect(token.balanceOf(Z_OWNER, TOKEN_ID)).toEqual(AMOUNT - 3n); + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT - 3n); }); it('should fail when not enough balance to burn', () => { expect(() => { - token._burn(Z_OWNER, TOKEN_ID, AMOUNT + 1n); + token._burn(OWNER.either, TOKEN_ID, AMOUNT + 1n); }).toThrow('MultiToken: insufficient balance'); }); it('should fail when burning the zero address tokens', () => { expect(() => { - token._burn(utils.ZERO_KEY, TOKEN_ID, AMOUNT); + token._burn(ZERO_ACCOUNT, TOKEN_ID, AMOUNT); }).toThrow('MultiToken: invalid sender'); }); it('should fail when burning tokens from nonexistent id', () => { expect(() => { - token._burn(Z_OWNER, NONEXISTENT_ID, AMOUNT); + token._burn(OWNER.either, NONEXISTENT_ID, AMOUNT); }).toThrow('MultiToken: insufficient balance'); }); + + it('should handle non-canonical fromAddress (id)', () => { + const nonCanonical = nonCanonicalLeft(OWNER.accountId); + token._burn(nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + }); + + it('should handle non-canonical fromAddress (contract address)', () => { + token._unsafeMint(OWNER_CONTRACT, TOKEN_ID, AMOUNT); + expect(token.balanceOf(OWNER_CONTRACT, TOKEN_ID)).toEqual(AMOUNT); + + const nonCanonical = nonCanonicalRight(OWNER_CONTRACT.right); + token._burn(nonCanonical, TOKEN_ID, AMOUNT); + + expect(token.balanceOf(OWNER_CONTRACT, TOKEN_ID)).toEqual(0n); + }); }); describe('_setApprovalForAll', () => { it('should return false when set to false', () => { - token._setApprovalForAll(Z_OWNER, Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token._setApprovalForAll(OWNER.either, SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); }); it('should fail when attempting to approve zero address as an operator', () => { expect(() => { - token._setApprovalForAll(Z_OWNER, utils.ZERO_KEY, true); + token._setApprovalForAll(OWNER.either, ZERO_ACCOUNT, true); }).toThrow('MultiToken: invalid operator'); }); + it('should fail when owner is zero address', () => { + expect(() => { + token._setApprovalForAll(ZERO_ACCOUNT, SPENDER.either, true); + }).toThrow('MultiToken: invalid owner'); + }); + it('should set → unset → set operator', () => { - token._setApprovalForAll(Z_OWNER, Z_SPENDER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + token._setApprovalForAll(OWNER.either, SPENDER.either, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe(true); + + token._setApprovalForAll(OWNER.either, SPENDER.either, false); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe( + false, + ); - token._setApprovalForAll(Z_OWNER, Z_SPENDER, false); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(false); + token._setApprovalForAll(OWNER.either, SPENDER.either, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe(true); + }); + + it('should canonicalize owner and operator', () => { + const nonCanonicalOwner = nonCanonicalLeft(OWNER.accountId); + const nonCanonicalOp = nonCanonicalLeft(SPENDER.accountId); + + token._setApprovalForAll(nonCanonicalOwner, nonCanonicalOp, true); + expect(token.isApprovedForAll(OWNER.either, SPENDER.either)).toBe(true); + }); + }); + + describe('ZERO', () => { + it('should return a left variant', () => { + const zero = token.ZERO(); + expect(zero.is_left).toBe(true); + }); + + it('should have zero left branch', () => { + const zero = token.ZERO(); + expect(zero.left).toEqual(zeroBytes); + }); + + it('should have zero right branch', () => { + const zero = token.ZERO(); + expect(zero.right).toEqual({ bytes: zeroBytes }); + }); + + it('should be canonical', () => { + const zero = token.ZERO(); + expect(zero).toEqual(ZERO_ACCOUNT); + }); - token._setApprovalForAll(Z_OWNER, Z_SPENDER, true); - expect(token.isApprovedForAll(Z_OWNER, Z_SPENDER)).toBe(true); + it('should not equal a right-variant zero', () => { + const zero = token.ZERO(); + expect(zero).not.toEqual(ZERO_CONTRACT); }); }); }); diff --git a/contracts/src/token/test/mocks/MockMultiToken.compact b/contracts/src/token/test/mocks/MockMultiToken.compact index e66f2884..fab386ce 100644 --- a/contracts/src/token/test/mocks/MockMultiToken.compact +++ b/contracts/src/token/test/mocks/MockMultiToken.compact @@ -10,7 +10,7 @@ pragma language_version >= 0.21.0; import CompactStandardLibrary; import "../../MultiToken" prefix MultiToken_; -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ContractAddress, Either, Maybe }; export { MultiToken__balances, MultiToken__operatorApprovals, MultiToken__uri }; /** @@ -27,6 +27,10 @@ constructor( } } +export pure circuit ZERO(): Either, ContractAddress> { + return MultiToken_ZERO(); +} + export circuit initialize(_uri: Opaque<"string">): [] { return MultiToken_initialize(_uri); } @@ -35,24 +39,24 @@ export circuit uri(id: Uint<128>): Opaque<"string"> { return MultiToken_uri(id); } -export circuit balanceOf(account: Either, id: Uint<128>): Uint<128> { +export circuit balanceOf(account: Either, ContractAddress>, id: Uint<128>): Uint<128> { return MultiToken_balanceOf(account, id); } -export circuit setApprovalForAll(operator: Either, approved: Boolean): [] { +export circuit setApprovalForAll(operator: Either, ContractAddress>, approved: Boolean): [] { return MultiToken_setApprovalForAll(operator, approved); } export circuit isApprovedForAll( - account: Either, - operator: Either + account: Either, ContractAddress>, + operator: Either, ContractAddress> ): Boolean { return MultiToken_isApprovedForAll(account, operator); } export circuit transferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { @@ -60,8 +64,8 @@ export circuit transferFrom( } export circuit _unsafeTransferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { @@ -69,8 +73,8 @@ export circuit _unsafeTransferFrom( } export circuit _transfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { @@ -78,8 +82,8 @@ export circuit _transfer( } export circuit _unsafeTransfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, id: Uint<128>, value: Uint<128> ): [] { @@ -90,22 +94,26 @@ export circuit _setURI(newURI: Opaque<"string">): [] { return MultiToken__setURI(newURI); } -export circuit _mint(to: Either, id: Uint<128>, value: Uint<128>): [] { +export circuit _mint(to: Either, ContractAddress>, id: Uint<128>, value: Uint<128>): [] { return MultiToken__mint(to, id, value); } -export circuit _unsafeMint(to: Either, id: Uint<128>, value: Uint<128>): [] { +export circuit _unsafeMint(to: Either, ContractAddress>, id: Uint<128>, value: Uint<128>): [] { return MultiToken__unsafeMint(to, id, value); } -export circuit _burn(fromAddress: Either, id: Uint<128>, value: Uint<128>): [] { +export circuit _burn(fromAddress: Either, ContractAddress>, id: Uint<128>, value: Uint<128>): [] { return MultiToken__burn(fromAddress, id, value); } export circuit _setApprovalForAll( - owner: Either, - operator: Either, + owner: Either, ContractAddress>, + operator: Either, ContractAddress>, approved: Boolean ): [] { return MultiToken__setApprovalForAll(owner, operator, approved); } + +export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> { + return MultiToken_computeAccountId(secretKey); +} diff --git a/contracts/src/token/test/simulators/MultiTokenSimulator.ts b/contracts/src/token/test/simulators/MultiTokenSimulator.ts index 324a797b..ff945c45 100644 --- a/contracts/src/token/test/simulators/MultiTokenSimulator.ts +++ b/contracts/src/token/test/simulators/MultiTokenSimulator.ts @@ -8,7 +8,6 @@ import { ledger, type Maybe, Contract as MockMultiToken, - type ZswapCoinPublicKey, } from '../../../../artifacts/MockMultiToken/contract/index.js'; import { MultiTokenPrivateState, @@ -29,7 +28,7 @@ const MultiTokenSimulatorBase = createSimulator< >({ contractFactory: (witnesses) => new MockMultiToken(witnesses), - defaultPrivateState: () => MultiTokenPrivateState, + defaultPrivateState: () => MultiTokenPrivateState.generate(), contractArgs: (_uri) => [_uri], ledgerExtractor: (state) => ledger(state), witnessesFactory: () => MultiTokenWitnesses(), @@ -49,6 +48,10 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { super([_uri], options); } + public ZERO(): Either { + return this.circuits.pure.ZERO(); + } + /** * @description Initializes the contract. This is already executed in the simulator constructor; * however, this method enables the tests to assert it cannot be called again. @@ -74,7 +77,7 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @returns The quantity of `id` tokens that `account` owns. */ public balanceOf( - account: Either, + account: Either, id: bigint, ): bigint { return this.circuits.impure.balanceOf(account, id); @@ -82,12 +85,12 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { /** * @description Enables or disables approval for `operator` to manage all of the caller's assets. - * @param operator The ZswapCoinPublicKey or ContractAddress whose approval is set for the caller's assets. + * @param operator The Uint8Array or ContractAddress whose approval is set for the caller's assets. * @param approved The boolean value determining if the operator may or may not handle the * caller's assets. */ public setApprovalForAll( - operator: Either, + operator: Either, approved: boolean, ) { this.circuits.impure.setApprovalForAll(operator, approved); @@ -100,8 +103,8 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @returns Whether or not `operator` has permission to handle `account`'s assets. */ public isApprovedForAll( - account: Either, - operator: Either, + account: Either, + operator: Either, ): boolean { return this.circuits.impure.isApprovedForAll(account, operator); } @@ -115,8 +118,8 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens to transfer. */ public transferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, id: bigint, value: bigint, ) { @@ -132,8 +135,8 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens to transfer. */ public _unsafeTransferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, id: bigint, value: bigint, ) { @@ -150,8 +153,8 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens to transfer. */ public _transfer( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, id: bigint, value: bigint, ) { @@ -168,8 +171,8 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens to transfer. */ public _unsafeTransfer( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, id: bigint, value: bigint, ) { @@ -191,7 +194,7 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens that are minted to `to`. */ public _mint( - to: Either, + to: Either, id: bigint, value: bigint, ) { @@ -205,7 +208,7 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens that are minted to `to`. */ public _unsafeMint( - to: Either, + to: Either, id: bigint, value: bigint, ) { @@ -219,7 +222,7 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { * @param value The quantity of `id` tokens that will be destroyed from `fromAddress` */ public _burn( - fromAddress: Either, + fromAddress: Either, id: bigint, value: bigint, ) { @@ -228,17 +231,55 @@ export class MultiTokenSimulator extends MultiTokenSimulatorBase { /** * @description Enables or disables approval for `operator` to manage all of the caller's assets. - * @param owner The ZswapCoinPublicKey or ContractAddress of the target owner. - * @param operator The ZswapCoinPublicKey or ContractAddress whose approval is set for the + * @param owner The Uint8Array or ContractAddress of the target owner. + * @param operator The Uint8Array or ContractAddress whose approval is set for the * `owner`'s assets. * @param approved The boolean value determining if the operator may or may not handle the * `owner`'s assets. */ public _setApprovalForAll( - owner: Either, - operator: Either, + owner: Either, + operator: Either, approved: boolean, ) { this.circuits.impure._setApprovalForAll(owner, operator, approved); } + + /** + * @description Computes an account identifier without on-chain state, allowing a user to derive + * their identity commitment before submitting it in a grant or revoke operation. + * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + * @returns {Bytes<32>} accountId - The computed account identifier. + */ + public computeAccountId(secretKey: Uint8Array): Uint8Array { + return this.circuits.pure.computeAccountId(secretKey); + } + + public readonly privateState = { + /** + * @description Replaces the secret key in the private state. Used in tests to + * simulate switching between different user identities or injecting incorrect + * keys to test failure paths. + * @param newSK - The new secret key to set. + * @returns The updated private state. + */ + injectSecretKey: (newSK: Uint8Array): MultiTokenPrivateState => { + const updatedState = MultiTokenPrivateState.withSecretKey(newSK); + this.circuitContextManager.updatePrivateState(updatedState); + return updatedState; + }, + + /** + * @description Returns the current secret key from the private state. + * @returns The secret key. + * @throws If the secret key is undefined. + */ + getCurrentSecretKey: (): Uint8Array => { + const sk = this.getPrivateState().secretKey; + if (typeof sk === 'undefined') { + throw new Error('Missing secret key'); + } + return Uint8Array.from(sk); + }, + }; } diff --git a/contracts/src/token/witnesses/MultiTokenWitnesses.ts b/contracts/src/token/witnesses/MultiTokenWitnesses.ts index 20bfddbc..bdf1e611 100644 --- a/contracts/src/token/witnesses/MultiTokenWitnesses.ts +++ b/contracts/src/token/witnesses/MultiTokenWitnesses.ts @@ -1,6 +1,80 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/witnesses/MultiTokenWitnesses.ts) -export type MultiTokenPrivateState = Record; -export const MultiTokenPrivateState: MultiTokenPrivateState = {}; -export const MultiTokenWitnesses = () => ({}); +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; + +/** + * @description Interface defining the witness methods for MultiToken operations. + * @template P - The private state type. + */ +export interface IMultiTokenWitnesses { + /** + * Retrieves the secret key from the private state. + * @param context - The witness context containing the private state. + * @returns A tuple of the private state and the secret key as a Uint8Array. + */ + wit_MultiTokenSK(context: WitnessContext): [P, Uint8Array]; +} + +/** + * @description Represents the private state of an MultiToken contract, storing a secret key. + */ +export type MultiTokenPrivateState = { + /** @description A 32-byte secret key used for creating a public user identifier. */ + secretKey: Uint8Array; +}; + +/** + * @description Utility object for managing the private state of an MultiToken contract. + */ +export const MultiTokenPrivateState = { + /** + * @description Generates a new private state with a random secret key. + * @returns A fresh MultiTokenPrivateState instance. + */ + generate: (): MultiTokenPrivateState => { + return { secretKey: getRandomValues(new Uint8Array(32)) }; + }, + + /** + * @description Generates a new private state with a user-defined secret key. + * Useful for deterministic key generation or advanced use cases. + * + * @param sk - The 32-byte secret key to use. + * @returns A fresh MultiTokenPrivateState instance with the provided key. + * + * @example + * ```typescript + * // For deterministic keys (user-defined scheme) + * const deterministicKey = myDeterministicScheme(...); + * const privateState = MultiTokenPrivateState.withSecretKey(deterministicKey); + * ``` + */ + withSecretKey: (sk: Uint8Array): MultiTokenPrivateState => { + if (sk.length !== 32) { + throw new Error( + `withSecretKey: expected 32-byte secret key, received ${sk.length} bytes`, + ); + } + return { secretKey: Uint8Array.from(sk) }; + }, +}; + +/** + * @description Factory function creating witness implementations for MultiToken operations. + * @returns An object implementing the Witnesses interface for MultiTokenPrivateState. + */ +export const MultiTokenWitnesses = (): IMultiTokenWitnesses< + L, + MultiTokenPrivateState +> => ({ + wit_MultiTokenSK( + context: WitnessContext, + ): [MultiTokenPrivateState, Uint8Array] { + return [ + context.privateState, + Uint8Array.from(context.privateState.secretKey), + ]; + }, +}); diff --git a/contracts/src/token/witnesses/test/MultiTokenWitnesses.test.ts b/contracts/src/token/witnesses/test/MultiTokenWitnesses.test.ts new file mode 100644 index 00000000..e759856c --- /dev/null +++ b/contracts/src/token/witnesses/test/MultiTokenWitnesses.test.ts @@ -0,0 +1,135 @@ +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import type { Ledger } from '../../../../artifacts/MockMultiToken/contract/index.js'; +import { + MultiTokenPrivateState, + MultiTokenWitnesses, +} from '../MultiTokenWitnesses.js'; + +const SECRET_KEY = new Uint8Array(32).fill(0x34); + +describe('MultiTokenPrivateState', () => { + describe('generate', () => { + it('should return a state with a 32-byte secretKey', () => { + const state = MultiTokenPrivateState.generate(); + expect(state.secretKey).toBeInstanceOf(Uint8Array); + expect(state.secretKey.length).toBe(32); + }); + + it('should produce unique secret key on successive calls', () => { + const a = MultiTokenPrivateState.generate(); + const b = MultiTokenPrivateState.generate(); + expect(a.secretKey).not.toEqual(b.secretKey); + }); + }); + + describe('withSecretKey', () => { + it('should accept a valid 32-byte secret key', () => { + const state = MultiTokenPrivateState.withSecretKey(SECRET_KEY); + expect(state.secretKey).toEqual(SECRET_KEY); + }); + + it('should create a defensive copy of the input secret key', () => { + const sk = new Uint8Array(32).fill(0xcc); + const state = MultiTokenPrivateState.withSecretKey(sk); + + sk.fill(0xff); + expect(state.secretKey).toEqual(new Uint8Array(32).fill(0xcc)); + }); + + it('should throw for a secret key shorter than 32 bytes', () => { + const short = new Uint8Array(16); + expect(() => MultiTokenPrivateState.withSecretKey(short)).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 16 bytes', + ); + }); + + it('should throw for a secret key longer than 32 bytes', () => { + const long = new Uint8Array(64); + expect(() => MultiTokenPrivateState.withSecretKey(long)).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 64 bytes', + ); + }); + + it('should throw for an empty array', () => { + expect(() => + MultiTokenPrivateState.withSecretKey(new Uint8Array(0)), + ).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 0 bytes', + ); + }); + }); +}); + +describe('MultiTokenWitnesses', () => { + const witnesses = MultiTokenWitnesses(); + + function makeContext( + privateState: MultiTokenPrivateState, + ): WitnessContext { + return { privateState } as WitnessContext; + } + + describe('wit_MultiTokenSK', () => { + it('should return a tuple of [privateState, secretKey]', () => { + const state = MultiTokenPrivateState.withSecretKey(SECRET_KEY); + const ctx = makeContext(state); + + const [returnedState, returnedSK] = witnesses.wit_MultiTokenSK(ctx); + + expect(returnedState).toBe(state); + expect(returnedSK).toEqual(SECRET_KEY); + }); + + it('should return the exact same privateState reference', () => { + const state = MultiTokenPrivateState.generate(); + const ctx = makeContext(state); + + const [returnedState] = witnesses.wit_MultiTokenSK(ctx); + expect(returnedState).toBe(state); + }); + + it('should return the secretKey as a Uint8Array', () => { + const state = MultiTokenPrivateState.generate(); + const ctx = makeContext(state); + + const [, returnedSK] = witnesses.wit_MultiTokenSK(ctx); + expect(returnedSK).toBeInstanceOf(Uint8Array); + expect(returnedSK.length).toBe(32); + }); + + it('should work with a randomly generated state', () => { + const state = MultiTokenPrivateState.generate(); + const ctx = makeContext(state); + + const [returnedState, returnedSK] = witnesses.wit_MultiTokenSK(ctx); + + expect(returnedState).toBe(state); + expect(returnedSK).toEqual(state.secretKey); + }); + }); +}); + +describe('MultiTokenWitnesses factory', () => { + it('should return a fresh witnesses object on each call', () => { + const a = MultiTokenWitnesses(); + const b = MultiTokenWitnesses(); + expect(a).not.toBe(b); + }); + + it('should produce witnesses with identical behaviour', () => { + const a = MultiTokenWitnesses(); + const b = MultiTokenWitnesses(); + const state = MultiTokenPrivateState.generate(); + const ctx = { privateState: state } as WitnessContext< + Ledger, + MultiTokenPrivateState + >; + + const [stateA, skA] = a.wit_MultiTokenSK(ctx); + const [stateB, skB] = b.wit_MultiTokenSK(ctx); + + expect(stateA).toBe(stateB); + expect(skA).toEqual(skB); + }); +});