diff --git a/contracts/src/token/FungibleToken.compact b/contracts/src/token/FungibleToken.compact index 1c0ab92a..23208333 100644 --- a/contracts/src/token/FungibleToken.compact +++ b/contracts/src/token/FungibleToken.compact @@ -13,6 +13,15 @@ pragma language_version >= 0.21.0; * This is due to encoding limits on the midnight circuit backend: * https://github.com/midnightntwrk/compactc/issues/929 * + * @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 `_approve` (for `_allowances`). Read paths are + * canonicalized in `balanceOf` and `allowance`. `_spendAllowance` canonicalizes + * independently to ensure consistent lookups before delegating to `_approve`. + * * @notice At the moment Midnight does not support contract-to-contract communication, but * there are ongoing efforts to enable this in the future. Thus, the main circuits of this module * restrict developers from sending tokens to contracts; however, we provide developers @@ -46,22 +55,22 @@ module FungibleToken { /** * @description Mapping from account addresses to their token balances. - * @type {Either} account - The account address. + * @type {Either, ContractAddress>} account - The account address. * @type {Uint<128>} balance - The balance of the account. * @type {Map} - * @type {Map, Uint<128>>} _balances + * @type {Map, ContractAddress>, Uint<128>>} _balances */ - export ledger _balances: Map, Uint<128>>; + export ledger _balances: Map, ContractAddress>, Uint<128>>; /** * @description Mapping from owner accounts to spender accounts and their allowances. - * @type {Either} account - The owner account address. - * @type {Either} spender - The spender account address. + * @type {Either, ContractAddress>} account - The owner account address. + * @type {Either, ContractAddress>} spender - The spender account address. * @type {Uint<128>} allowance - The amount allowed to be spent by the spender. * @type {Map>} - * @type {Map, Map, Uint<128>>>} _allowances + * @type {Map, ContractAddress>, Map, ContractAddress>, Uint<128>>>} _allowances */ - export ledger _allowances: Map, - Map, Uint<128>>>; + export ledger _allowances: Map, ContractAddress>, + Map, ContractAddress>, Uint<128>>>; export ledger _totalSupply: Uint<128>; @@ -69,6 +78,31 @@ module FungibleToken { export sealed ledger _symbol: Opaque<"string">; export sealed ledger _decimals: Uint<8>; + + /** + * @witness wit_FungibleTokenSK + * @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_FungibleTokenSK(): Bytes<32>; + + /** + * @description Returns a canonical zero Either value for Bytes<32> and ContractAddress. + * This circuit returns the left variant (Bytes<32>) to avoid misleading contract-to-contract + * error messages. + * + * @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 name, symbol, and decimals. * @dev This MUST be called in the implementing contract's constructor. Failure to do so @@ -95,7 +129,7 @@ module FungibleToken { /** * @description Returns the token name. * - * @circuitInfo k=10, rows=37 + * @circuitInfo k=6, rows=28 * * Requirements: * @@ -111,7 +145,7 @@ module FungibleToken { /** * @description Returns the symbol of the token. * - * @circuitInfo k=10, rows=37 + * @circuitInfo k=6, rows=28 * * Requirements: * @@ -127,7 +161,7 @@ module FungibleToken { /** * @description Returns the number of decimals used to get its user representation. * - * @circuitInfo k=10, rows=36 + * @circuitInfo k=6, rows=28 * * Requirements: * @@ -143,7 +177,7 @@ module FungibleToken { /** * @description Returns the value of tokens in existence. * - * @circuitInfo k=10, rows=36 + * @circuitInfo k=6, rows=28 * * Requirements: * @@ -159,7 +193,7 @@ module FungibleToken { /** * @description Returns the value of tokens owned by `account`. * - * @circuitInfo k=10, rows=310 + * @circuitInfo k=10, rows=673 * * @dev Manually checks if `account` is a key in the map and returns 0 if it is not. * @@ -167,22 +201,24 @@ module FungibleToken { * * - Contract is initialized. * - * @param {Either} account - The public key or contract address to query. + * @param {Either, ContractAddress>} account - The account id or contract address to query. * @return {Uint<128>} - The account's token balance. */ - export circuit balanceOf(account: Either): Uint<128> { + export circuit balanceOf(account: Either, ContractAddress>): Uint<128> { Initializable_assertInitialized(); - if (!_balances.member(disclose(account))) { + const canonAcct = Utils_canonicalize, ContractAddress>(account); + + if (!_balances.member(disclose(canonAcct))) { return 0; } - return _balances.lookup(disclose(account)); + return _balances.lookup(disclose(canonAcct)); } /** * @description Moves a `value` amount of tokens from the caller's account to `to`. * - * @circuitInfo k=11, rows=1173 + * @circuitInfo k=13, rows=3985 * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from @@ -195,23 +231,25 @@ module FungibleToken { * - `to` is not the zero address. * - The caller has a balance of at least `value`. * - * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Either, ContractAddress>} to - The recipient of the transfer, either a user or a contract. * @param {Uint<128>} value - The amount to transfer. * @return {Boolean} - As per the IERC20 spec, this MUST return true. */ export circuit transfer( - to: Either, + to: Either, ContractAddress>, value: Uint<128> ): Boolean { Initializable_assertInitialized(); - assert(!Utils_isContractAddress(to), "FungibleToken: Unsafe Transfer"); + const isContractAddr = !to.is_left; + assert(!isContractAddr, "FungibleToken: unsafe transfer"); + return _unsafeTransfer(to, value); } /** * @description Unsafe variant of `transfer` which allows transfers to contract addresses. * - * @circuitInfo k=11, rows=1170 + * @circuitInfo k=13, rows=3982 * * @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. @@ -223,16 +261,16 @@ module FungibleToken { * - `to` is not the zero address. * - The caller has a balance of at least `value`. * - * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Either, ContractAddress>} to - The recipient of the transfer, either a user or a contract. * @param {Uint<128>} value - The amount to transfer. * @return {Boolean} - As per the IERC20 spec, this MUST return true. */ export circuit _unsafeTransfer( - to: Either, + to: Either, ContractAddress>, value: Uint<128> ): Boolean { Initializable_assertInitialized(); - const owner = left(ownPublicKey()); + const owner = left, ContractAddress>(_computeAccountId()); _unsafeUncheckedTransfer(owner, to, value); return true; } @@ -241,7 +279,7 @@ module FungibleToken { * @description Returns the remaining number of tokens that `spender` will be allowed to spend on behalf of `owner` * through `transferFrom`. This value changes when `approve` or `transferFrom` are called. * - * @circuitInfo k=10, rows=624 + * @circuitInfo k=11, rows=1346 * * @dev Manually checks if `owner` and `spender` are keys in the map and returns 0 if they are not. * @@ -249,42 +287,45 @@ module FungibleToken { * * - Contract is initialized. * - * @param {Either} owner - The public key or contract address of approver. - * @param {Either} spender - The public key or contract address of spender. + * @param {Either, ContractAddress>} owner - The account id or contract address of approver. + * @param {Either, ContractAddress>} spender - The account id or contract address of spender. * @return {Uint<128>} - The `spender`'s allowance over `owner`'s tokens. */ export circuit allowance( - owner: Either, - spender: Either + owner: Either, ContractAddress>, + spender: Either, ContractAddress> ): Uint<128> { Initializable_assertInitialized(); - if (!_allowances.member(disclose(owner)) || !_allowances.lookup(owner).member(disclose(spender))) { + const canonOwner = Utils_canonicalize, ContractAddress>(owner); + const canonSpender = Utils_canonicalize, ContractAddress>(spender); + + if (!_allowances.member(disclose(canonOwner)) || !_allowances.lookup(canonOwner).member(disclose(canonSpender))) { return 0; } - return _allowances.lookup(owner).lookup(disclose(spender)); + return _allowances.lookup(canonOwner).lookup(disclose(canonSpender)); } /** * @description Sets a `value` amount of tokens as allowance of `spender` over the caller's tokens. * - * @circuitInfo k=10, rows=452 + * @circuitInfo k=13, rows=3072 * * Requirements: * * - Contract is initialized. * - `spender` is not the zero address. * - * @param {Either} spender - The Zswap key or ContractAddress that may spend on behalf of the caller. + * @param {Either, ContractAddress>} spender - The account id or ContractAddress that may spend on behalf of the caller. * @param {Uint<128>} value - The amount of tokens the `spender` may spend. * @return {Boolean} - Returns a boolean value indicating whether the operation succeeded. */ - export circuit approve(spender: Either, + export circuit approve(spender: Either, ContractAddress>, value: Uint<128> ): Boolean { Initializable_assertInitialized(); - const owner = left(ownPublicKey()); + const owner = left, ContractAddress>(_computeAccountId()); _approve(owner, spender, value); return true; } @@ -293,7 +334,7 @@ module FungibleToken { * @description Moves `value` tokens from `fromAddress` to `to` using the allowance mechanism. * `value` is the deducted from the caller's allowance. * - * @circuitInfo k=11, rows=1821 + * @circuitInfo k=13, rows=4960 * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from @@ -308,25 +349,26 @@ module FungibleToken { * - `to` is not a ContractAddress. * - The caller has an allowance of `fromAddress`'s tokens of at least `value`. * - * @param {Either} fromAddress - The current owner of the tokens for the transfer, either a user or a contract. - * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Either, ContractAddress>} fromAddress - The current owner of the tokens for the transfer, either a user or a contract. + * @param {Either, ContractAddress>} to - The recipient of the transfer, either a user or a contract. * @param {Uint<128>} value - The amount to transfer. * @return {Boolean} - As per the IERC20 spec, this MUST return true. */ export circuit transferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): Boolean { Initializable_assertInitialized(); - assert(!Utils_isContractAddress(to), "FungibleToken: Unsafe Transfer"); + const isContractAddr = !to.is_left; + assert(!isContractAddr, "FungibleToken: unsafe transfer"); return _unsafeTransferFrom(fromAddress, to, value); } /** * @description Unsafe variant of `transferFrom` which allows transfers to contract addresses. * - * @circuitInfo k=11, rows=1818 + * @circuitInfo k=13, rows=4957 * * @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. @@ -340,19 +382,19 @@ module FungibleToken { * - `to` is not the zero address. * - The caller has an allowance of `fromAddress`'s tokens of at least `value`. * - * @param {Either} fromAddress - The current owner of the tokens for the transfer, either a user or a contract. - * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Either, ContractAddress>} fromAddress - The current owner of the tokens for the transfer, either a user or a contract. + * @param {Either, ContractAddress>} to - The recipient of the transfer, either a user or a contract. * @param {Uint<128>} value - The amount to transfer. * @return {Boolean} - As per the IERC20 spec, this MUST return true. */ export circuit _unsafeTransferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): Boolean { Initializable_assertInitialized(); - const spender = left(ownPublicKey()); + const spender = left, ContractAddress>(_computeAccountId()); _spendAllowance(fromAddress, spender, value); _unsafeUncheckedTransfer(fromAddress, to, value); return true; @@ -363,7 +405,7 @@ module FungibleToken { * This circuit is equivalent to {transfer}, and can be used to * e.g. implement automatic token fees, slashing mechanisms, etc. * - * @circuitInfo k=11, rows=1312 + * @circuitInfo k=12, rows=2345 * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from @@ -377,25 +419,26 @@ module FungibleToken { * - `to` must not be the zero address. * - `to` must not be a ContractAddress. * - * @param {Either} fromAddress - The owner of the tokens to transfer. - * @param {Either} to - The receipient of the transferred tokens. + * @param {Either, ContractAddress>} fromAddress - The owner of the tokens to transfer. + * @param {Either, ContractAddress>} to - The receipient of the transferred tokens. * @param {Uint<128>} value - The amount of tokens to transfer. * @return {[]} - Empty tuple. */ export circuit _transfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): [] { Initializable_assertInitialized(); - assert(!Utils_isContractAddress(to), "FungibleToken: Unsafe Transfer"); + const isContractAddr = !to.is_left; + assert(!isContractAddr, "FungibleToken: unsafe transfer"); _unsafeUncheckedTransfer(fromAddress, to, value); } /** * @description Unsafe variant of `transferFrom` which allows transfers to contract addresses. * - * @circuitInfo k=11, rows=1309 + * @circuitInfo k=12, rows=2342 * * @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. @@ -407,19 +450,19 @@ module FungibleToken { * - `fromAddress` is not the zero address. * - `to` is not the zero address. * - * @param {Either} fromAddress - The owner of the tokens to transfer. - * @param {Either} to - The receipient of the transferred tokens. + * @param {Either, ContractAddress>} fromAddress - The owner of the tokens to transfer. + * @param {Either, ContractAddress>} to - The receipient of the transferred tokens. * @param {Uint<128>} value - The amount of tokens to transfer. * @return {[]} - Empty tuple. */ export circuit _unsafeUncheckedTransfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): [] { Initializable_assertInitialized(); - assert(!Utils_isKeyOrAddressZero(fromAddress), "FungibleToken: invalid sender"); - assert(!Utils_isKeyOrAddressZero(to), "FungibleToken: invalid receiver"); + assert(!_isTargetZero(fromAddress), "FungibleToken: invalid sender"); + assert(!_isTargetZero(to), "FungibleToken: invalid receiver"); _update(fromAddress, to, value); } @@ -435,34 +478,37 @@ module FungibleToken { * * - Contract is initialized. * - * @param {Either} fromAddress - The original owner of the tokens moved (which is 0 if tokens are minted). - * @param {Either} to - The recipient of the tokens moved (which is 0 if tokens are burned). + * @param {Either, ContractAddress>} fromAddress - The original owner of the tokens moved (which is 0 if tokens are minted). + * @param {Either, ContractAddress>} to - The recipient of the tokens moved (which is 0 if tokens are burned). * @param {Uint<128>} value - The amount of tokens moved from `fromAddress` to `to`. * @return {[]} - Empty tuple. */ - circuit _update(fromAddress: Either, - to: Either, + circuit _update(fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): [] { Initializable_assertInitialized(); - if (Utils_isKeyOrAddressZero(disclose(fromAddress))) { + const canonFrom = Utils_canonicalize, ContractAddress>(fromAddress); + const canonTo = Utils_canonicalize, ContractAddress>(to); + + if (_isTargetZero(disclose(canonFrom))) { // Mint const MAX_UINT128 = 340282366920938463463374607431768211455; assert(MAX_UINT128 - _totalSupply >= value, "FungibleToken: arithmetic overflow"); _totalSupply = disclose(_totalSupply + value as Uint<128>); } else { - const fromBal = balanceOf(fromAddress); + const fromBal = balanceOf(canonFrom); assert(fromBal >= value, "FungibleToken: insufficient balance"); - _balances.insert(disclose(fromAddress), disclose(fromBal - value as Uint<128>)); + _balances.insert(disclose(canonFrom), disclose(fromBal - value as Uint<128>)); } - if (Utils_isKeyOrAddressZero(disclose(to))) { + if (_isTargetZero(disclose(canonTo))) { // Burn _totalSupply = disclose(_totalSupply - value as Uint<128>); } else { - const toBal = balanceOf(to); - _balances.insert(disclose(to), disclose(toBal + value as Uint<128>)); + const toBal = balanceOf(canonTo); + _balances.insert(disclose(canonTo), disclose(toBal + value as Uint<128>)); } } @@ -470,7 +516,7 @@ module FungibleToken { * @description Creates a `value` amount of tokens and assigns them to `account`, * by transferring it from the zero address. Relies on the `update` mechanism. * - * @circuitInfo k=10, rows=752 + * @circuitInfo k=11, rows=1437 * * @notice Transfers to contract addresses are currently disallowed until contract-to-contract * interactions are supported in Compact. This restriction prevents assets from @@ -482,20 +528,21 @@ module FungibleToken { * - `account` is not a ContractAddress. * - `account` is not the zero address. * - * @param {Either} account - The recipient of tokens minted. + * @param {Either, ContractAddress>} account - The recipient of tokens minted. * @param {Uint<128>} value - The amount of tokens minted. * @return {[]} - Empty tuple. */ - export circuit _mint(account: Either, value: Uint<128>): [] { + export circuit _mint(account: Either, ContractAddress>, value: Uint<128>): [] { Initializable_assertInitialized(); - assert(!Utils_isContractAddress(account), "FungibleToken: Unsafe Transfer"); + const isContractAddr = !account.is_left; + assert(!isContractAddr, "FungibleToken: unsafe transfer"); _unsafeMint(account, value); } /** * @description Unsafe variant of `_mint` which allows transfers to contract addresses. * - * @circuitInfo k=10, rows=749 + * @circuitInfo k=11, rows=1434 * * @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. @@ -506,24 +553,24 @@ module FungibleToken { * - Contract is initialized. * - `account` is not the zero address. * - * @param {Either} account - The recipient of tokens minted. + * @param {Either, ContractAddress>} account - The recipient of tokens minted. * @param {Uint<128>} value - The amount of tokens minted. * @return {[]} - Empty tuple. */ export circuit _unsafeMint( - account: Either, + account: Either, ContractAddress>, value: Uint<128> ): [] { Initializable_assertInitialized(); - assert(!Utils_isKeyOrAddressZero(account), "FungibleToken: invalid receiver"); - _update(shieldedBurnAddress(), account, value); + assert(!_isTargetZero(account), "FungibleToken: invalid receiver"); + _update(ZERO(), account, value); } /** * @description Destroys a `value` amount of tokens from `account`, lowering the total supply. * Relies on the `_update` mechanism. * - * @circuitInfo k=10, rows=773 + * @circuitInfo k=11, rows=1377 * * Requirements: * @@ -531,85 +578,151 @@ module FungibleToken { * - `account` is not the zero address. * - `account` must have at least a balance of `value`. * - * @param {Either} account - The target owner of tokens to burn. + * @param {Either, ContractAddress>} account - The target owner of tokens to burn. * @param {Uint<128>} value - The amount of tokens to burn. * @return {[]} - Empty tuple. */ - export circuit _burn(account: Either, value: Uint<128>): [] { + export circuit _burn(account: Either, ContractAddress>, value: Uint<128>): [] { Initializable_assertInitialized(); - assert(!Utils_isKeyOrAddressZero(account), "FungibleToken: invalid sender"); - _update(account, shieldedBurnAddress(), value); + assert(!_isTargetZero(account), "FungibleToken: invalid sender"); + _update(account, ZERO(), value); } /** * @description Sets `value` as the allowance of `spender` over the `owner`'s tokens. * This circuit is equivalent to `approve`, and can be used to - * - * @circuitInfo k=10, rows=583 - * * e.g. set automatic allowances for certain subsystems, etc. * + * @circuitInfo k=11, rows=1406 + * * Requirements: * * - Contract is initialized. * - `owner` is not the zero address. * - `spender` is not the zero address. * - * @param {Either} owner - The owner of the tokens. - * @param {Either} spender - The spender of the tokens. + * @param {Either, ContractAddress>} owner - The owner of the tokens. + * @param {Either, ContractAddress>} spender - The spender of the tokens. * @param {Uint<128>} value - The amount of tokens `spender` may spend on behalf of `owner`. * @return {[]} - Empty tuple. */ export circuit _approve( - owner: Either, - spender: Either, + owner: Either, ContractAddress>, + spender: Either, ContractAddress>, value: Uint<128> ): [] { Initializable_assertInitialized(); - assert(!Utils_isKeyOrAddressZero(owner), "FungibleToken: invalid owner"); - assert(!Utils_isKeyOrAddressZero(spender), "FungibleToken: invalid spender"); - if (!_allowances.member(disclose(owner))) { + const canonOwner = Utils_canonicalize, ContractAddress>(owner); + const canonSpender = Utils_canonicalize, ContractAddress>(spender); + + assert(!_isTargetZero(canonOwner), "FungibleToken: invalid owner"); + assert(!_isTargetZero(canonSpender), "FungibleToken: invalid spender"); + + if (!_allowances.member(disclose(canonOwner))) { // If owner doesn't exist, create and insert a new sub-map directly _allowances.insert( - disclose(owner), - default, Uint<128>>> + disclose(canonOwner), + default, ContractAddress>, Uint<128>>> ); } - _allowances.lookup(owner).insert(disclose(spender), disclose(value)); + _allowances.lookup(canonOwner).insert(disclose(canonSpender), disclose(value)); } /** * @description Updates `owner`'s allowance for `spender` based on spent `value`. * Does not update the allowance value in case of infinite allowance. * - * @circuitInfo k=10, rows=931 + * @circuitInfo k=11, rows=1729 * * Requirements: * * - Contract is initialized. * - `spender` must have at least an allowance of `value` from `owner`. * - * @param {Either} owner - The owner of the tokens. - * @param {Either} spender - The spender of the tokens. + * @param {Either, ContractAddress>} owner - The owner of the tokens. + * @param {Either, ContractAddress>} spender - The spender of the tokens. * @param {Uint<128>} value - The amount of token allowance to spend. * @return {[]} - Empty tuple. */ export circuit _spendAllowance( - owner: Either, - spender: Either, + owner: Either, ContractAddress>, + spender: Either, ContractAddress>, value: Uint<128> ): [] { Initializable_assertInitialized(); - assert((_allowances.member(disclose(owner)) && - _allowances.lookup(owner).member(disclose(spender))), + const canonOwner = Utils_canonicalize, ContractAddress>(owner); + const canonSpender = Utils_canonicalize, ContractAddress>(spender); + + assert((_allowances.member(disclose(canonOwner)) && + _allowances.lookup(canonOwner).member(disclose(canonSpender))), "FungibleToken: insufficient allowance" ); - const currentAllowance = _allowances.lookup(owner).lookup(disclose(spender)); + const currentAllowance = _allowances.lookup(canonOwner).lookup(disclose(canonSpender)); const MAX_UINT128 = 340282366920938463463374607431768211455; if (currentAllowance < MAX_UINT128) { assert(currentAllowance >= value, "FungibleToken: insufficient allowance"); - _approve(owner, spender, currentAllowance - value as Uint<128>); + _approve(canonOwner, canonSpender, currentAllowance - value as Uint<128>); + } + } + + /** + * @description Computes the caller's account identifier from the `wit_FungibleTokenSK` 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_FungibleTokenSK()); + } + + /** + * @description Computes an account identifier without on-chain state, allowing a user to derive + * their identity commitment before submitting 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/FungibleToken.test.ts b/contracts/src/token/test/FungibleToken.test.ts index 8a2cdc1a..9acfc209 100644 --- a/contracts/src/token/test/FungibleToken.test.ts +++ b/contracts/src/token/test/FungibleToken.test.ts @@ -1,7 +1,69 @@ +import { + CompactTypeBytes, + CompactTypeVector, + persistentHash, +} from '@midnight-ntwrk/compact-runtime'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/address.js'; import { FungibleTokenSimulator } from './simulators/FungibleTokenSimulator.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 }; +}; + +// 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 }, +}; + // Metadata const EMPTY_STRING = ''; const NAME = 'NAME'; @@ -12,41 +74,25 @@ const INIT = true; const BAD_INIT = false; // Amounts -const AMOUNT: bigint = BigInt(250); -const MAX_UINT128 = BigInt(2 ** 128) - BigInt(1); - -// 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 [, Z_OTHER] = utils.generateEitherPubKeyPair('OTHER'); - -// Encoded contract addresses -const Z_OWNER_CONTRACT = - utils.createEitherTestContractAddress('OWNER_CONTRACT'); -const Z_RECIPIENT_CONTRACT = - utils.createEitherTestContractAddress('RECIPIENT_CONTRACT'); - -// Helper types +const AMOUNT = 250n; +const MAX_UINT128 = (1n << 128n) - 1n; + +let token: FungibleTokenSimulator; + const ownerTypes = [ - ['contract', Z_OWNER_CONTRACT], - ['pubkey', Z_OWNER], + ['contract', OWNER_CONTRACT], + ['accountId', OWNER.either], ] as const; const recipientTypes = [ - ['contract', Z_RECIPIENT_CONTRACT], - ['pubkey', Z_RECIPIENT], + ['contract', RECIPIENT_CONTRACT], + ['accountId', RECIPIENT.either], ] as const; -let token: FungibleTokenSimulator; - describe('FungibleToken', () => { describe('before initialization', () => { it('should initialize metadata', () => { token = new FungibleTokenSimulator(NAME, SYMBOL, DECIMALS, INIT); - expect(token.name()).toEqual(NAME); expect(token.symbol()).toEqual(SYMBOL); expect(token.decimals()).toEqual(DECIMALS); @@ -59,7 +105,6 @@ describe('FungibleToken', () => { NO_DECIMALS, INIT, ); - expect(token.name()).toEqual(EMPTY_STRING); expect(token.symbol()).toEqual(EMPTY_STRING); expect(token.decimals()).toEqual(NO_DECIMALS); @@ -80,25 +125,24 @@ describe('FungibleToken', () => { method: keyof FungibleTokenSimulator, args: unknown[], ]; - // Circuit calls should fail before the args are used const circuitsToFail: FailingCircuits[] = [ ['name', []], ['symbol', []], ['decimals', []], ['totalSupply', []], - ['balanceOf', [Z_OWNER]], - ['allowance', [Z_OWNER, Z_SPENDER]], - ['transfer', [Z_RECIPIENT, AMOUNT]], - ['_unsafeTransfer', [Z_RECIPIENT, AMOUNT]], - ['transferFrom', [Z_OWNER, Z_RECIPIENT, AMOUNT]], - ['_unsafeTransferFrom', [Z_OWNER, Z_RECIPIENT, AMOUNT]], - ['approve', [Z_OWNER, AMOUNT]], - ['_approve', [Z_OWNER, Z_SPENDER, AMOUNT]], - ['_transfer', [Z_OWNER, Z_RECIPIENT, AMOUNT]], - ['_unsafeUncheckedTransfer', [Z_OWNER, Z_RECIPIENT, AMOUNT]], - ['_mint', [Z_OWNER, AMOUNT]], - ['_unsafeMint', [Z_OWNER, AMOUNT]], - ['_burn', [Z_OWNER, AMOUNT]], + ['balanceOf', [OWNER.either]], + ['allowance', [OWNER.either, SPENDER.either]], + ['transfer', [RECIPIENT.either, AMOUNT]], + ['_unsafeTransfer', [RECIPIENT.either, AMOUNT]], + ['transferFrom', [OWNER.either, RECIPIENT.either, AMOUNT]], + ['_unsafeTransferFrom', [OWNER.either, RECIPIENT.either, AMOUNT]], + ['approve', [OWNER.either, AMOUNT]], + ['_approve', [OWNER.either, SPENDER.either, AMOUNT]], + ['_transfer', [OWNER.either, RECIPIENT.either, AMOUNT]], + ['_unsafeUncheckedTransfer', [OWNER.either, RECIPIENT.either, AMOUNT]], + ['_mint', [OWNER.either, AMOUNT]], + ['_unsafeMint', [OWNER.either, AMOUNT]], + ['_burn', [OWNER.either, AMOUNT]], ]; it.each(circuitsToFail)('%s should fail', (circuitName, args) => { @@ -113,13 +157,40 @@ describe('FungibleToken', () => { token = new FungibleTokenSimulator(NAME, SYMBOL, DECIMALS, INIT); }); + 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); + }); + + it('should not equal a right-variant zero', () => { + const zero = token.ZERO(); + expect(zero).not.toEqual(ZERO_CONTRACT); + }); + }); + describe('totalSupply', () => { it('returns 0 when there is no supply', () => { expect(token.totalSupply()).toEqual(0n); }); it('returns the amount of existing tokens when there is a supply', () => { - token._mint(Z_OWNER, AMOUNT); + token._mint(OWNER.either, AMOUNT); expect(token.totalSupply()).toEqual(AMOUNT); }); }); @@ -135,14 +206,95 @@ describe('FungibleToken', () => { expect(token.balanceOf(owner)).toEqual(AMOUNT); }); }); + + it('should return correct balance with non-canonical lookup (left)', () => { + token._mint(OWNER.either, AMOUNT); + + const nonCanonical = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + expect(token.balanceOf(nonCanonical)).toEqual(AMOUNT); + }); + + it('should return correct balance with non-canonical lookup (right)', () => { + token._unsafeMint(OWNER_CONTRACT, AMOUNT); + + const nonCanonical = { + is_left: false, + left: new Uint8Array(32).fill(1), + right: OWNER_CONTRACT.right, + }; + + expect(token.balanceOf(nonCanonical)).toEqual(AMOUNT); + }); + }); + + describe('allowance', () => { + it('should return correct allowance with non-canonical owner lookup (left)', () => { + token._approve(OWNER.either, SPENDER.either, AMOUNT); + + const nonCanonicalOwner = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + expect(token.allowance(nonCanonicalOwner, SPENDER.either)).toEqual( + AMOUNT, + ); + }); + + it('should return correct allowance with non-canonical spender lookup (left)', () => { + token._approve(OWNER.either, SPENDER.either, AMOUNT); + + const nonCanonicalSpender = { + is_left: true, + left: SPENDER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + expect(token.allowance(OWNER.either, nonCanonicalSpender)).toEqual( + AMOUNT, + ); + }); + + it('should return correct allowance with non-canonical owner lookup (right)', () => { + token._approve(OWNER_CONTRACT, SPENDER.either, AMOUNT); + + const nonCanonicalOwner = { + is_left: false, + left: new Uint8Array(32).fill(1), + right: OWNER_CONTRACT.right, + }; + + expect(token.allowance(nonCanonicalOwner, SPENDER.either)).toEqual( + AMOUNT, + ); + }); + + it('should return correct allowance with non-canonical spender lookup (right)', () => { + token._approve(OWNER.either, RECIPIENT_CONTRACT, AMOUNT); + + const nonCanonicalSpender = { + is_left: false, + left: new Uint8Array(32).fill(1), + right: RECIPIENT_CONTRACT.right, + }; + + expect(token.allowance(OWNER.either, nonCanonicalSpender)).toEqual( + AMOUNT, + ); + }); }); describe('transfer', () => { beforeEach(() => { - token._mint(Z_OWNER, AMOUNT); - - expect(token.balanceOf(Z_OWNER)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(0n); + token._mint(OWNER.either, AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either)).toEqual(0n); }); afterEach(() => { @@ -150,58 +302,78 @@ describe('FungibleToken', () => { }); it('should transfer partial', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + const partialAmt = AMOUNT - 1n; - const txSuccess = token.as(OWNER).transfer(Z_RECIPIENT, partialAmt); + const txSuccess = token.transfer(RECIPIENT.either, partialAmt); expect(txSuccess).toBe(true); - expect(token.balanceOf(Z_OWNER)).toEqual(1n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(partialAmt); + expect(token.balanceOf(OWNER.either)).toEqual(1n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(partialAmt); }); it('should transfer full', () => { - const txSuccess = token.as(OWNER).transfer(Z_RECIPIENT, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + + const txSuccess = token.transfer(RECIPIENT.either, AMOUNT); expect(txSuccess).toBe(true); - expect(token.balanceOf(Z_OWNER)).toEqual(0n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT); }); it('should fail with insufficient balance', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + expect(() => { - token.as(OWNER).transfer(Z_RECIPIENT, AMOUNT + 1n); + token.transfer(RECIPIENT.either, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient balance'); }); - it('should fail with transfer from zero', () => { + it('should fail with transfer from zero identity', () => { + // Inject a key that produces zero accountId — infeasible in practice, + // but we can test the zero check by using _unsafeUncheckedTransfer directly expect(() => { - token.as(ZERO).transfer(Z_RECIPIENT, AMOUNT); + token._unsafeUncheckedTransfer( + ZERO_ACCOUNT, + RECIPIENT.either, + AMOUNT, + ); }).toThrow('FungibleToken: invalid sender'); }); it('should fail with transfer to zero', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + expect(() => { - token.as(OWNER).transfer(utils.ZERO_KEY, AMOUNT); + token.transfer(ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); it('should allow transfer of 0 tokens', () => { - const txSuccess = token.as(OWNER).transfer(Z_RECIPIENT, 0n); + token.privateState.injectSecretKey(OWNER.secretKey); + + const txSuccess = token.transfer(RECIPIENT.either, 0n); expect(txSuccess).toBe(true); - expect(token.balanceOf(Z_OWNER)).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either)).toEqual(0n); }); it('should handle transfer with empty _balances', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token.as(SPENDER).transfer(Z_RECIPIENT, 1n); + token.transfer(RECIPIENT.either, 1n); }).toThrow('FungibleToken: insufficient balance'); }); it('should fail when transferring to a contract', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + expect(() => { - token.transfer(Z_OWNER_CONTRACT, AMOUNT); - }).toThrow('FungibleToken: Unsafe Transfer'); + token.transfer(OWNER_CONTRACT, AMOUNT); + }).toThrow('FungibleToken: unsafe transfer'); }); }); @@ -210,9 +382,8 @@ describe('FungibleToken', () => { recipientTypes, )('when the recipient is a %s', (_, recipient) => { beforeEach(() => { - token._mint(Z_OWNER, AMOUNT); - - expect(token.balanceOf(Z_OWNER)).toEqual(AMOUNT); + token._mint(OWNER.either, AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); expect(token.balanceOf(recipient)).toEqual(0n); }); @@ -221,122 +392,136 @@ describe('FungibleToken', () => { }); it('should transfer partial', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + const partialAmt = AMOUNT - 1n; - const txSuccess = token - .as(OWNER) - ._unsafeTransfer(recipient, partialAmt); + const txSuccess = token._unsafeTransfer(recipient, partialAmt); expect(txSuccess).toBe(true); - expect(token.balanceOf(Z_OWNER)).toEqual(1n); + expect(token.balanceOf(OWNER.either)).toEqual(1n); expect(token.balanceOf(recipient)).toEqual(partialAmt); }); it('should transfer full', () => { - const txSuccess = token.as(OWNER)._unsafeTransfer(recipient, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + + const txSuccess = token._unsafeTransfer(recipient, AMOUNT); expect(txSuccess).toBe(true); - expect(token.balanceOf(Z_OWNER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); expect(token.balanceOf(recipient)).toEqual(AMOUNT); }); it('should fail with insufficient balance', () => { - expect(() => { - token.as(OWNER)._unsafeTransfer(recipient, AMOUNT + 1n); - }).toThrow('FungibleToken: insufficient balance'); - }); + token.privateState.injectSecretKey(OWNER.secretKey); - it('should fail with transfer from zero', () => { expect(() => { - token.as(ZERO)._unsafeTransfer(recipient, AMOUNT); - }).toThrow('FungibleToken: invalid sender'); + token._unsafeTransfer(recipient, AMOUNT + 1n); + }).toThrow('FungibleToken: insufficient balance'); }); it('should allow transfer of 0 tokens', () => { - const txSuccess = token.as(OWNER)._unsafeTransfer(recipient, 0n); + token.privateState.injectSecretKey(OWNER.secretKey); + + const txSuccess = token._unsafeTransfer(recipient, 0n); expect(txSuccess).toBe(true); - expect(token.balanceOf(Z_OWNER)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); expect(token.balanceOf(recipient)).toEqual(0n); }); it('should handle transfer with empty _balances', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token.as(SPENDER)._unsafeTransfer(recipient, 1n); + token._unsafeTransfer(recipient, 1n); }).toThrow('FungibleToken: insufficient balance'); }); }); - it('should fail with transfer to zero (pk)', () => { + it('should fail with transfer to zero (accountId)', () => { + token._mint(OWNER.either, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + expect(() => { - token.as(OWNER)._unsafeTransfer(utils.ZERO_KEY, AMOUNT); + token._unsafeTransfer(ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); it('should fail with transfer to zero (contract)', () => { + token._mint(OWNER.either, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + expect(() => { - token.as(OWNER)._unsafeTransfer(utils.ZERO_ADDRESS, AMOUNT); + token._unsafeTransfer(ZERO_CONTRACT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); }); describe('approve', () => { beforeEach(() => { - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); }); it('should approve and update allowance', () => { - token.as(OWNER).approve(Z_SPENDER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + + token.approve(SPENDER.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(AMOUNT); }); it('should approve and update allowance for multiple spenders', () => { - token.as(OWNER).approve(Z_SPENDER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); - token.as(OWNER).approve(Z_OTHER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_OTHER)).toEqual(AMOUNT); + token.approve(SPENDER.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(AMOUNT); - expect(token.allowance(Z_OWNER, Z_RECIPIENT)).toEqual(0n); - }); + token.approve(OTHER.either, AMOUNT); + expect(token.allowance(OWNER.either, OTHER.either)).toEqual(AMOUNT); - it('should fail when approve from zero', () => { - expect(() => { - token.as(ZERO).approve(Z_SPENDER, AMOUNT); - }).toThrow('FungibleToken: invalid owner'); + expect(token.allowance(OWNER.either, RECIPIENT.either)).toEqual(0n); }); it('should fail when approve to zero', () => { + token.privateState.injectSecretKey(OWNER.secretKey); + expect(() => { - token.as(OWNER).approve(utils.ZERO_KEY, AMOUNT); + token.approve(ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid spender'); }); it('should transfer exact allowance and fail subsequent transfer', () => { - token._mint(Z_OWNER, AMOUNT); - token.as(OWNER).approve(Z_SPENDER, AMOUNT); + token._mint(OWNER.either, AMOUNT); + + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, AMOUNT); - token.as(SPENDER).transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + token.privateState.injectSecretKey(SPENDER.secretKey); + token.transferFrom(OWNER.either, RECIPIENT.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); expect(() => { - token.as(SPENDER).transferFrom(Z_OWNER, Z_RECIPIENT, 1n); + token.transferFrom(OWNER.either, RECIPIENT.either, 1n); }).toThrow('FungibleToken: insufficient allowance'); }); it('should allow approve of 0 tokens', () => { - token.as(OWNER).approve(Z_SPENDER, 0n); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + token.privateState.injectSecretKey(OWNER.secretKey); + + token.approve(SPENDER.either, 0n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); }); it('should handle allowance with empty _allowances', () => { - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); }); }); describe('transferFrom', () => { beforeEach(() => { - token.as(OWNER).approve(Z_SPENDER, AMOUNT); - token._mint(Z_OWNER, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, AMOUNT); + token._mint(OWNER.either, AMOUNT); }); afterEach(() => { @@ -344,92 +529,103 @@ describe('FungibleToken', () => { }); it('should transferFrom spender (partial)', () => { - const partialAmt = AMOUNT - 1n; + token.privateState.injectSecretKey(SPENDER.secretKey); - const txSuccess = token - .as(SPENDER) - .transferFrom(Z_OWNER, Z_RECIPIENT, partialAmt); + const partialAmt = AMOUNT - 1n; + const txSuccess = token.transferFrom( + OWNER.either, + RECIPIENT.either, + partialAmt, + ); expect(txSuccess).toBe(true); - // Check balances - expect(token.balanceOf(Z_OWNER)).toEqual(1n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(partialAmt); - // Check leftover allowance - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(1n); + expect(token.balanceOf(OWNER.either)).toEqual(1n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(partialAmt); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(1n); }); it('should transferFrom spender (full)', () => { - const txSuccess = token - .as(SPENDER) - .transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT); + token.privateState.injectSecretKey(SPENDER.secretKey); + + const txSuccess = token.transferFrom( + OWNER.either, + RECIPIENT.either, + AMOUNT, + ); expect(txSuccess).toBe(true); - // Check balances - expect(token.balanceOf(Z_OWNER)).toEqual(0n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(AMOUNT); - // Check no allowance - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); }); it('should transferFrom and not consume infinite allowance', () => { - token.as(OWNER).approve(Z_SPENDER, MAX_UINT128); - - const txSuccess = token - .as(SPENDER) - .transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, MAX_UINT128); + + token.privateState.injectSecretKey(SPENDER.secretKey); + const txSuccess = token.transferFrom( + OWNER.either, + RECIPIENT.either, + AMOUNT, + ); expect(txSuccess).toBe(true); - // Check balances - expect(token.balanceOf(Z_OWNER)).toEqual(0n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(AMOUNT); - // Check infinite allowance - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(MAX_UINT128); + expect(token.balanceOf(OWNER.either)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual( + MAX_UINT128, + ); }); it('should fail when transfer amount exceeds allowance', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token.as(SPENDER).transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT + 1n); + token.transferFrom(OWNER.either, RECIPIENT.either, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient allowance'); }); it('should fail when transfer amount exceeds balance', () => { - // Increase allowance > balance - token.as(OWNER).approve(Z_SPENDER, AMOUNT + 1n); + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, AMOUNT + 1n); + token.privateState.injectSecretKey(SPENDER.secretKey); expect(() => { - token.as(SPENDER).transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT + 1n); + token.transferFrom(OWNER.either, RECIPIENT.either, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient balance'); }); it('should fail when spender does not have allowance', () => { - expect(() => { - token.as(UNAUTHORIZED).transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT); - }).toThrow('FungibleToken: insufficient allowance'); - }); + token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); - it('should fail to transferFrom zero address', () => { expect(() => { - token.as(ZERO).transferFrom(Z_OWNER, Z_RECIPIENT, AMOUNT); + token.transferFrom(OWNER.either, RECIPIENT.either, AMOUNT); }).toThrow('FungibleToken: insufficient allowance'); }); it('should fail to transferFrom to the zero address', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token.as(SPENDER).transferFrom(Z_OWNER, utils.ZERO_KEY, AMOUNT); + token.transferFrom(OWNER.either, ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); it('should fail when transferring to a contract', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token.as(OWNER).transferFrom(Z_OWNER, Z_OWNER_CONTRACT, AMOUNT); - }).toThrow('FungibleToken: Unsafe Transfer'); + token.transferFrom(OWNER.either, OWNER_CONTRACT, AMOUNT); + }).toThrow('FungibleToken: unsafe transfer'); }); }); describe('_unsafeTransferFrom', () => { beforeEach(() => { - token.as(OWNER).approve(Z_SPENDER, AMOUNT); - token._mint(Z_OWNER, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, AMOUNT); + token._mint(OWNER.either, AMOUNT); }); afterEach(() => { @@ -440,102 +636,102 @@ describe('FungibleToken', () => { recipientTypes, )('when the recipient is a %s', (_, recipient) => { it('should transferFrom spender (partial)', () => { - const partialAmt = AMOUNT - 1n; + token.privateState.injectSecretKey(SPENDER.secretKey); - const txSuccess = token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, recipient, partialAmt); + const partialAmt = AMOUNT - 1n; + const txSuccess = token._unsafeTransferFrom( + OWNER.either, + recipient, + partialAmt, + ); expect(txSuccess).toBe(true); - // Check balances - expect(token.balanceOf(Z_OWNER)).toEqual(1n); + expect(token.balanceOf(OWNER.either)).toEqual(1n); expect(token.balanceOf(recipient)).toEqual(partialAmt); - // Check leftover allowance - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(1n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(1n); }); it('should transferFrom spender (full)', () => { - const txSuccess = token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, recipient, AMOUNT); + token.privateState.injectSecretKey(SPENDER.secretKey); + + const txSuccess = token._unsafeTransferFrom( + OWNER.either, + recipient, + AMOUNT, + ); expect(txSuccess).toBe(true); - // Check balances - expect(token.balanceOf(Z_OWNER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); expect(token.balanceOf(recipient)).toEqual(AMOUNT); - // Check no allowance - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); }); it('should transferFrom and not consume infinite allowance', () => { - token.as(OWNER).approve(Z_SPENDER, MAX_UINT128); - - const txSuccess = token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, recipient, AMOUNT); + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, MAX_UINT128); + + token.privateState.injectSecretKey(SPENDER.secretKey); + const txSuccess = token._unsafeTransferFrom( + OWNER.either, + recipient, + AMOUNT, + ); expect(txSuccess).toBe(true); - // Check balances - expect(token.balanceOf(Z_OWNER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); expect(token.balanceOf(recipient)).toEqual(AMOUNT); - // Check infinite allowance - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(MAX_UINT128); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual( + MAX_UINT128, + ); }); it('should fail when transfer amount exceeds allowance', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, recipient, AMOUNT + 1n); + token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient allowance'); }); it('should fail when transfer amount exceeds balance', () => { - // Increase allowance > balance - token.as(OWNER).approve(Z_SPENDER, AMOUNT + 1n); + token.privateState.injectSecretKey(OWNER.secretKey); + token.approve(SPENDER.either, AMOUNT + 1n); + token.privateState.injectSecretKey(SPENDER.secretKey); expect(() => { - token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, recipient, AMOUNT + 1n); + token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient balance'); }); it('should fail when spender does not have allowance', () => { - expect(() => { - token - .as(UNAUTHORIZED) - ._unsafeTransferFrom(Z_OWNER, recipient, AMOUNT); - }).toThrow('FungibleToken: insufficient allowance'); - }); + token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); - it('should fail to transfer from the zero address', () => { expect(() => { - token.as(ZERO)._unsafeTransferFrom(Z_OWNER, recipient, AMOUNT); + token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT); }).toThrow('FungibleToken: insufficient allowance'); }); }); - it('should fail to transfer to the zero address (pk)', () => { + it('should fail to transfer to the zero address (accountId)', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, utils.ZERO_KEY, AMOUNT); + token._unsafeTransferFrom(OWNER.either, ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); it('should fail to transfer to the zero address (contract)', () => { + token.privateState.injectSecretKey(SPENDER.secretKey); + expect(() => { - token - .as(SPENDER) - ._unsafeTransferFrom(Z_OWNER, utils.ZERO_ADDRESS, AMOUNT); + token._unsafeTransferFrom(OWNER.either, ZERO_CONTRACT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); }); describe('_transfer', () => { beforeEach(() => { - token._mint(Z_OWNER, AMOUNT); + token._mint(OWNER.either, AMOUNT); }); afterEach(() => { @@ -544,22 +740,22 @@ describe('FungibleToken', () => { it('should update balances (partial)', () => { const partialAmt = AMOUNT - 1n; - token._transfer(Z_OWNER, Z_RECIPIENT, partialAmt); + token._transfer(OWNER.either, RECIPIENT.either, partialAmt); - expect(token.balanceOf(Z_OWNER)).toEqual(1n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(partialAmt); + expect(token.balanceOf(OWNER.either)).toEqual(1n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(partialAmt); }); it('should fail when transferring to a contract', () => { expect(() => { - token._transfer(Z_OWNER, Z_OWNER_CONTRACT, AMOUNT); - }).toThrow('FungibleToken: Unsafe Transfer'); + token._transfer(OWNER.either, OWNER_CONTRACT, AMOUNT); + }).toThrow('FungibleToken: unsafe transfer'); }); }); describe('_unsafeUncheckedTransfer', () => { beforeEach(() => { - token._mint(Z_OWNER, AMOUNT); + token._mint(OWNER.either, AMOUNT); }); afterEach(() => { @@ -571,88 +767,127 @@ describe('FungibleToken', () => { )('when the recipient is a %s', (_, recipient) => { it('should update balances (partial)', () => { const partialAmt = AMOUNT - 1n; - token._unsafeUncheckedTransfer(Z_OWNER, recipient, partialAmt); + token._unsafeUncheckedTransfer(OWNER.either, recipient, partialAmt); - expect(token.balanceOf(Z_OWNER)).toEqual(1n); + expect(token.balanceOf(OWNER.either)).toEqual(1n); expect(token.balanceOf(recipient)).toEqual(partialAmt); }); it('should update balances (full)', () => { - token._unsafeUncheckedTransfer(Z_OWNER, recipient, AMOUNT); + token._unsafeUncheckedTransfer(OWNER.either, recipient, AMOUNT); - expect(token.balanceOf(Z_OWNER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); expect(token.balanceOf(recipient)).toEqual(AMOUNT); }); it('should fail when transfer amount exceeds balance', () => { expect(() => { - token._unsafeUncheckedTransfer(Z_OWNER, recipient, AMOUNT + 1n); + token._unsafeUncheckedTransfer( + OWNER.either, + recipient, + AMOUNT + 1n, + ); }).toThrow('FungibleToken: insufficient balance'); }); it('should fail when transfer from zero', () => { expect(() => { - token._unsafeUncheckedTransfer( - utils.ZERO_ADDRESS, - recipient, - AMOUNT, - ); + token._unsafeUncheckedTransfer(ZERO_CONTRACT, recipient, AMOUNT); }).toThrow('FungibleToken: invalid sender'); }); }); - it('should fail when transfer to zero (pk)', () => { + it('should fail when transfer to zero (accountId)', () => { expect(() => { - token._unsafeUncheckedTransfer(Z_OWNER, utils.ZERO_KEY, AMOUNT); + token._unsafeUncheckedTransfer(OWNER.either, ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); it('should fail when transfer to zero (contract)', () => { expect(() => { - token._unsafeUncheckedTransfer(Z_OWNER, utils.ZERO_ADDRESS, AMOUNT); + token._unsafeUncheckedTransfer(OWNER.either, ZERO_CONTRACT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); + + it('should canonicalize recipient (zero out inactive right side)', () => { + // Check init amt for recipient is zero + expect(token.balanceOf(RECIPIENT.either)).toEqual(0n); + + const nonCanonical = { + is_left: true, + left: RECIPIENT.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._unsafeUncheckedTransfer(OWNER.either, nonCanonical, AMOUNT); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT); + }); + + it('should canonicalize recipient contract address (zero out inactive left side)', () => { + const nonCanonical = { + is_left: false, + left: new Uint8Array(32).fill(1), + right: RECIPIENT_CONTRACT.right, + }; + + token._unsafeUncheckedTransfer(OWNER.either, nonCanonical, AMOUNT); + expect(token.balanceOf(RECIPIENT_CONTRACT)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(0n); + }); + + it('should canonicalize fromAddress (zero out inactive right side)', () => { + const nonCanonical = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._unsafeUncheckedTransfer(nonCanonical, RECIPIENT.either, AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(0n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT); + }); }); describe('_mint', () => { it('should mint and update supply', () => { expect(token.totalSupply()).toEqual(0n); - token._mint(Z_RECIPIENT, AMOUNT); + token._mint(RECIPIENT.either, AMOUNT); expect(token.totalSupply()).toEqual(AMOUNT); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(AMOUNT); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT); }); it('should catch mint overflow', () => { - token._mint(Z_RECIPIENT, MAX_UINT128); + token._mint(RECIPIENT.either, MAX_UINT128); expect(() => { - token._mint(Z_RECIPIENT, 1n); + token._mint(RECIPIENT.either, 1n); }).toThrow('FungibleToken: arithmetic overflow'); }); - it('should not mint to zero pubkey', () => { + it('should not mint to zero (accountId)', () => { expect(() => { - token._mint(utils.ZERO_KEY, AMOUNT); + token._mint(ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); - it('should not mint to zero contract address', () => { + it('should not mint to zero (contract)', () => { expect(() => { - token._mint(utils.ZERO_KEY, AMOUNT); - }).toThrow('FungibleToken: invalid receiver'); + // caught by unsafe transfer guard first + token._mint(ZERO_CONTRACT, AMOUNT); + }).toThrow('FungibleToken: unsafe transfer'); }); it('should allow mint of 0 tokens', () => { - token._mint(Z_OWNER, 0n); + token._mint(OWNER.either, 0n); expect(token.totalSupply()).toEqual(0n); - expect(token.balanceOf(Z_OWNER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); }); it('should fail when minting to a contract', () => { expect(() => { - token._mint(Z_OWNER_CONTRACT, AMOUNT); - }).toThrow('FungibleToken: Unsafe Transfer'); + token._mint(OWNER_CONTRACT, AMOUNT); + }).toThrow('FungibleToken: unsafe transfer'); }); }); @@ -683,156 +918,258 @@ describe('FungibleToken', () => { }); }); - it('should not mint to zero pubkey', () => { + it('should not mint to zero (accountId)', () => { expect(() => { - token._unsafeMint(utils.ZERO_KEY, AMOUNT); + token._unsafeMint(ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); - it('should not mint to zero contract address', () => { + it('should not mint to zero (contract)', () => { expect(() => { - token._unsafeMint(utils.ZERO_KEY, AMOUNT); + token._unsafeMint(ZERO_CONTRACT, AMOUNT); }).toThrow('FungibleToken: invalid receiver'); }); + + it('should canonicalize sender (zero out inactive right side)', () => { + const nonCanonical = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._unsafeMint(nonCanonical, AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); + }); }); describe('_burn', () => { beforeEach(() => { - token._mint(Z_OWNER, AMOUNT); + token._mint(OWNER.either, AMOUNT); }); it('should burn tokens', () => { - token._burn(Z_OWNER, 1n); + token._burn(OWNER.either, 1n); const afterBurn = AMOUNT - 1n; - expect(token.balanceOf(Z_OWNER)).toEqual(afterBurn); + expect(token.balanceOf(OWNER.either)).toEqual(afterBurn); expect(token.totalSupply()).toEqual(afterBurn); }); - it('should throw when burning from zero', () => { + it('should throw when burning from zero (accountId)', () => { + expect(() => { + token._burn(ZERO_ACCOUNT, AMOUNT); + }).toThrow('FungibleToken: invalid sender'); + }); + + it('should throw when burning from zero (contract)', () => { expect(() => { - token._burn(utils.ZERO_KEY, AMOUNT); + token._burn(ZERO_CONTRACT, AMOUNT); }).toThrow('FungibleToken: invalid sender'); }); it('should throw when burn amount is greater than balance', () => { expect(() => { - token._burn(Z_OWNER, AMOUNT + 1n); + token._burn(OWNER.either, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient balance'); }); it('should allow burn of 0 tokens', () => { - token._burn(Z_OWNER, 0n); + token._burn(OWNER.either, 0n); expect(token.totalSupply()).toEqual(AMOUNT); - expect(token.balanceOf(Z_OWNER)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); + }); + + it('should burn with non-canonical account (left)', () => { + const nonCanonical = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._burn(nonCanonical, 1n); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT - 1n); + expect(token.totalSupply()).toEqual(AMOUNT - 1n); }); }); describe('_approve', () => { beforeEach(() => { - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); }); it('should approve and update allowance', () => { - token._approve(Z_OWNER, Z_SPENDER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(AMOUNT); + token._approve(OWNER.either, SPENDER.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(AMOUNT); }); it('should approve and update allowance for multiple spenders', () => { - // Approve spender - token._approve(Z_OWNER, Z_SPENDER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(AMOUNT); + token._approve(OWNER.either, SPENDER.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(AMOUNT); - // Approve other - token._approve(Z_OWNER, Z_OTHER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_OTHER)).toEqual(AMOUNT); + token._approve(OWNER.either, OTHER.either, AMOUNT); + expect(token.allowance(OWNER.either, OTHER.either)).toEqual(AMOUNT); - expect(token.allowance(Z_OWNER, Z_RECIPIENT)).toEqual(0n); + expect(token.allowance(OWNER.either, RECIPIENT.either)).toEqual(0n); }); - it('should fail when approve from zero (pk)', () => { + it('should fail when approve from zero (accountId)', () => { expect(() => { - token._approve(utils.ZERO_KEY, Z_SPENDER, AMOUNT); + token._approve(ZERO_ACCOUNT, SPENDER.either, AMOUNT); }).toThrow('FungibleToken: invalid owner'); }); it('should fail when approve from zero (contract)', () => { expect(() => { - token._approve(utils.ZERO_ADDRESS, Z_SPENDER, AMOUNT); + token._approve(ZERO_CONTRACT, SPENDER.either, AMOUNT); }).toThrow('FungibleToken: invalid owner'); }); - it('should fail when approve to zero (pk)', () => { + it('should fail when approve to zero (accountId)', () => { expect(() => { - token._approve(Z_OWNER, utils.ZERO_KEY, AMOUNT); + token._approve(OWNER.either, ZERO_ACCOUNT, AMOUNT); }).toThrow('FungibleToken: invalid spender'); }); it('should fail when approve to zero (contract)', () => { expect(() => { - token._approve(Z_OWNER, utils.ZERO_ADDRESS, AMOUNT); + token._approve(OWNER.either, ZERO_CONTRACT, AMOUNT); }).toThrow('FungibleToken: invalid spender'); }); it('should allow approve of 0 tokens', () => { - token._approve(Z_OWNER, Z_SPENDER, 0n); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(0n); + token._approve(OWNER.either, SPENDER.either, 0n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); + }); + + it('should canonicalize owner in allowance (zero out inactive right side)', () => { + const nonCanonicalOwner = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._approve(nonCanonicalOwner, SPENDER.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(AMOUNT); + }); + + it('should canonicalize spender in allowance (zero out inactive right side)', () => { + const nonCanonicalSpender = { + is_left: true, + left: SPENDER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._approve(OWNER.either, nonCanonicalSpender, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(AMOUNT); + }); + + it('should canonicalize contract address owner (zero out inactive left side)', () => { + const nonCanonicalOwner = { + is_left: false, + left: new Uint8Array(32).fill(1), + right: OWNER_CONTRACT.right, + }; + + token._approve(nonCanonicalOwner, SPENDER.either, AMOUNT); + expect(token.allowance(OWNER_CONTRACT, SPENDER.either)).toEqual(AMOUNT); }); }); describe('_spendAllowance', () => { beforeEach(() => { - token._mint(Z_OWNER, AMOUNT); + token._mint(OWNER.either, AMOUNT); }); it('should update allowance when not unlimited', () => { - token._approve(Z_OWNER, Z_SPENDER, MAX_UINT128 - 1n); - token._spendAllowance(Z_OWNER, Z_SPENDER, AMOUNT); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual( + token._approve(OWNER.either, SPENDER.either, MAX_UINT128 - 1n); + token._spendAllowance(OWNER.either, SPENDER.either, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual( MAX_UINT128 - 1n - AMOUNT, ); }); it('should not update allowance when unlimited', () => { - token._approve(Z_OWNER, Z_SPENDER, MAX_UINT128); - token._spendAllowance(Z_OWNER, Z_SPENDER, MAX_UINT128 - 1n); - expect(token.allowance(Z_OWNER, Z_SPENDER)).toEqual(MAX_UINT128); + token._approve(OWNER.either, SPENDER.either, MAX_UINT128); + token._spendAllowance(OWNER.either, SPENDER.either, MAX_UINT128 - 1n); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual( + MAX_UINT128, + ); }); it('should fail when owner allowance is not initialized', () => { expect(() => { - token._spendAllowance(Z_OTHER, Z_SPENDER, AMOUNT); + token._spendAllowance(OTHER.either, SPENDER.either, AMOUNT); }).toThrow('FungibleToken: insufficient allowance'); }); it('should fail when spender is not initialized', () => { - token._approve(Z_OWNER, Z_SPENDER, AMOUNT); + token._approve(OWNER.either, SPENDER.either, AMOUNT); expect(() => { - token._spendAllowance(Z_OWNER, Z_OTHER, AMOUNT); + token._spendAllowance(OWNER.either, OTHER.either, AMOUNT); }).toThrow('FungibleToken: insufficient allowance'); }); it('should fail when spender has insufficient allowance', () => { - token._approve(Z_OWNER, Z_SPENDER, AMOUNT); + token._approve(OWNER.either, SPENDER.either, AMOUNT); expect(() => { - token._spendAllowance(Z_OWNER, Z_SPENDER, AMOUNT + 1n); + token._spendAllowance(OWNER.either, SPENDER.either, AMOUNT + 1n); }).toThrow('FungibleToken: insufficient allowance'); }); + + it('should canonicalize when spending allowance', () => { + token._approve(OWNER.either, SPENDER.either, AMOUNT); + + const nonCanonicalOwner = { + is_left: true, + left: OWNER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + const nonCanonicalSpender = { + is_left: true, + left: SPENDER.accountId, + right: utils.encodeToAddress('JUNK_DATA'), + }; + + token._spendAllowance(nonCanonicalOwner, nonCanonicalSpender, AMOUNT); + expect(token.allowance(OWNER.either, SPENDER.either)).toEqual(0n); + }); }); describe('Multiple Operations', () => { it('should handle mint → transfer → burn sequence', () => { - token._mint(Z_OWNER, AMOUNT); + token._mint(OWNER.either, AMOUNT); expect(token.totalSupply()).toEqual(AMOUNT); - expect(token.balanceOf(Z_OWNER)).toEqual(AMOUNT); + expect(token.balanceOf(OWNER.either)).toEqual(AMOUNT); - token.as(OWNER).transfer(Z_RECIPIENT, AMOUNT - 1n); - expect(token.balanceOf(Z_OWNER)).toEqual(1n); - expect(token.balanceOf(Z_RECIPIENT)).toEqual(AMOUNT - 1n); + token.privateState.injectSecretKey(OWNER.secretKey); + token.transfer(RECIPIENT.either, AMOUNT - 1n); + expect(token.balanceOf(OWNER.either)).toEqual(1n); + expect(token.balanceOf(RECIPIENT.either)).toEqual(AMOUNT - 1n); - token._burn(Z_OWNER, 1n); + token._burn(OWNER.either, 1n); expect(token.totalSupply()).toEqual(AMOUNT - 1n); - expect(token.balanceOf(Z_OWNER)).toEqual(0n); + expect(token.balanceOf(OWNER.either)).toEqual(0n); + }); + }); + describe('computeAccountId', () => { + const users = [OWNER, SPENDER, RECIPIENT, UNAUTHORIZED]; + + it('should match the test helper derivation', () => { + for (let i = 0; i < users.length; i++) { + expect(token.computeAccountId(users[i].secretKey)).toEqual( + users[i].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]); + } + } }); }); }); diff --git a/contracts/src/token/test/mocks/MockFungibleToken.compact b/contracts/src/token/test/mocks/MockFungibleToken.compact index 7e23d955..ace4c12b 100644 --- a/contracts/src/token/test/mocks/MockFungibleToken.compact +++ b/contracts/src/token/test/mocks/MockFungibleToken.compact @@ -11,7 +11,7 @@ import CompactStandardLibrary; import "../../FungibleToken" prefix FungibleToken_; -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ContractAddress, Either, Maybe }; /** * @description `init` is a param for testing. @@ -31,6 +31,10 @@ constructor( } } +export pure circuit ZERO(): Either, ContractAddress> { + return FungibleToken_ZERO(); +} + export circuit name(): Opaque<"string"> { return FungibleToken_name(); } @@ -47,94 +51,98 @@ export circuit totalSupply(): Uint<128> { return FungibleToken_totalSupply(); } -export circuit balanceOf(account: Either): Uint<128> { +export circuit balanceOf(account: Either, ContractAddress>): Uint<128> { return FungibleToken_balanceOf(account); } export circuit allowance( - owner: Either, - spender: Either + owner: Either, ContractAddress>, + spender: Either, ContractAddress> ): Uint<128> { return FungibleToken_allowance(owner, spender); } -export circuit transfer(to: Either, value: Uint<128>): Boolean { +export circuit transfer(to: Either, ContractAddress>, value: Uint<128>): Boolean { return FungibleToken_transfer(to, value); } -export circuit _unsafeTransfer(to: Either, value: Uint<128>): Boolean { +export circuit _unsafeTransfer(to: Either, ContractAddress>, value: Uint<128>): Boolean { return FungibleToken__unsafeTransfer(to, value); } export circuit transferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): Boolean { return FungibleToken_transferFrom(fromAddress, to, value); } export circuit _unsafeTransferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): Boolean { return FungibleToken__unsafeTransferFrom(fromAddress, to, value); } -export circuit approve(spender: Either, value: Uint<128>): Boolean { +export circuit approve(spender: Either, ContractAddress>, value: Uint<128>): Boolean { return FungibleToken_approve(spender, value); } export circuit _approve( - owner: Either, - spender: Either, + owner: Either, ContractAddress>, + spender: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__approve(owner, spender, value); } export circuit _transfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__transfer(fromAddress, to, value); } export circuit _unsafeUncheckedTransfer( - fromAddress: Either, - to: Either, + fromAddress: Either, ContractAddress>, + to: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__unsafeUncheckedTransfer(fromAddress, to, value); } export circuit _mint( - account: Either, + account: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__mint(account, value); } export circuit _unsafeMint( - account: Either, + account: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__unsafeMint(account, value); } export circuit _burn( - account: Either, + account: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__burn(account, value); } export circuit _spendAllowance( - owner: Either, - spender: Either, + owner: Either, ContractAddress>, + spender: Either, ContractAddress>, value: Uint<128> ): [] { return FungibleToken__spendAllowance(owner, spender, value); } + +export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> { + return FungibleToken_computeAccountId(secretKey); +} diff --git a/contracts/src/token/test/simulators/FungibleTokenSimulator.ts b/contracts/src/token/test/simulators/FungibleTokenSimulator.ts index 54dda0e0..bb467430 100644 --- a/contracts/src/token/test/simulators/FungibleTokenSimulator.ts +++ b/contracts/src/token/test/simulators/FungibleTokenSimulator.ts @@ -7,7 +7,6 @@ import { type Either, ledger, Contract as MockFungibleToken, - type ZswapCoinPublicKey, } from '../../../../artifacts/MockFungibleToken/contract/index.js'; import { FungibleTokenPrivateState, @@ -33,7 +32,7 @@ const FungibleTokenSimulatorBase = createSimulator< >({ contractFactory: (witnesses) => new MockFungibleToken(witnesses), - defaultPrivateState: () => FungibleTokenPrivateState, + defaultPrivateState: () => FungibleTokenPrivateState.generate(), contractArgs: (name, symbol, decimals, init) => [ name, symbol, @@ -60,6 +59,16 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { ) { super([name, symbol, decimals, init], options); } + /** + * @description Returns a canonical zero Either value for Bytes<32> and ContractAddress. + * This circuit returns the left variant (Bytes<32>) to avoid misleading contract-to-contract + * error messages. + * @returns The zero value. + */ + public ZERO(): Either { + return this.circuits.pure.ZERO(); + } + /** * @description Returns the token name. * @returns The token name. @@ -97,9 +106,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param account The public key or contract address to query. * @returns The account's token balance. */ - public balanceOf( - account: Either, - ): bigint { + public balanceOf(account: Either): bigint { return this.circuits.impure.balanceOf(account); } @@ -111,8 +118,8 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @returns The `spender`'s allowance over `owner`'s tokens. */ public allowance( - owner: Either, - spender: Either, + owner: Either, + spender: Either, ): bigint { return this.circuits.impure.allowance(owner, spender); } @@ -124,7 +131,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @returns As per the IERC20 spec, this MUST return true. */ public transfer( - to: Either, + to: Either, value: bigint, ): boolean { return this.circuits.impure.transfer(to, value); @@ -137,7 +144,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @returns As per the IERC20 spec, this MUST return true. */ public _unsafeTransfer( - to: Either, + to: Either, value: bigint, ): boolean { return this.circuits.impure._unsafeTransfer(to, value); @@ -152,8 +159,8 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @returns As per the IERC20 spec, this MUST return true. */ public transferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, value: bigint, ): boolean { return this.circuits.impure.transferFrom(fromAddress, to, value); @@ -167,8 +174,8 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @returns As per the IERC20 spec, this MUST return true. */ public _unsafeTransferFrom( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, value: bigint, ): boolean { return this.circuits.impure._unsafeTransferFrom(fromAddress, to, value); @@ -181,7 +188,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @returns Returns a boolean value indicating whether the operation succeeded. */ public approve( - spender: Either, + spender: Either, value: bigint, ): boolean { return this.circuits.impure.approve(spender, value); @@ -200,8 +207,8 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param value The amount of tokens to transfer. */ public _transfer( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, value: bigint, ) { this.circuits.impure._transfer(fromAddress, to, value); @@ -214,8 +221,8 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param value The amount of tokens to transfer. */ public _unsafeUncheckedTransfer( - fromAddress: Either, - to: Either, + fromAddress: Either, + to: Either, value: bigint, ) { this.circuits.impure._unsafeUncheckedTransfer(fromAddress, to, value); @@ -227,10 +234,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param account The recipient of tokens minted. * @param value The amount of tokens minted. */ - public _mint( - account: Either, - value: bigint, - ) { + public _mint(account: Either, value: bigint) { this.circuits.impure._mint(account, value); } @@ -240,7 +244,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param value The amount of tokens minted. */ public _unsafeMint( - account: Either, + account: Either, value: bigint, ) { this.circuits.impure._unsafeMint(account, value); @@ -252,10 +256,7 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param account The target owner of tokens to burn. * @param value The amount of tokens to burn. */ - public _burn( - account: Either, - value: bigint, - ) { + public _burn(account: Either, value: bigint) { this.circuits.impure._burn(account, value); } @@ -268,8 +269,8 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param value The amount of tokens `spender` may spend on behalf of `owner`. */ public _approve( - owner: Either, - spender: Either, + owner: Either, + spender: Either, value: bigint, ) { this.circuits.impure._approve(owner, spender, value); @@ -283,10 +284,48 @@ export class FungibleTokenSimulator extends FungibleTokenSimulatorBase { * @param value The amount of token allowance to spend. */ public _spendAllowance( - owner: Either, - spender: Either, + owner: Either, + spender: Either, value: bigint, ) { this.circuits.impure._spendAllowance(owner, spender, value); } + + /** + * @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): FungibleTokenPrivateState => { + const updatedState = FungibleTokenPrivateState.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/FungibleTokenWitnesses.ts b/contracts/src/token/witnesses/FungibleTokenWitnesses.ts index 613bf98a..f1cc7185 100644 --- a/contracts/src/token/witnesses/FungibleTokenWitnesses.ts +++ b/contracts/src/token/witnesses/FungibleTokenWitnesses.ts @@ -1,6 +1,80 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/witnesses/FungibleTokenWitnesses.ts) -export type FungibleTokenPrivateState = Record; -export const FungibleTokenPrivateState: FungibleTokenPrivateState = {}; -export const FungibleTokenWitnesses = () => ({}); +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; + +/** + * @description Interface defining the witness methods for FungibleToken operations. + * @template P - The private state type. + */ +export interface IFungibleTokenWitnesses { + /** + * 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_FungibleTokenSK(context: WitnessContext): [P, Uint8Array]; +} + +/** + * @description Represents the private state of a FungibleToken contract, storing a secret key. + */ +export type FungibleTokenPrivateState = { + /** @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 FungibleToken contract. + */ +export const FungibleTokenPrivateState = { + /** + * @description Generates a new private state with a random secret key. + * @returns A fresh FungibleTokenPrivateState instance. + */ + generate: (): FungibleTokenPrivateState => { + 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 FungibleTokenPrivateState instance with the provided key. + * + * @example + * ```typescript + * // For deterministic keys (user-defined scheme) + * const deterministicKey = myDeterministicScheme(...); + * const privateState = FungibleTokenPrivateState.withSecretKey(deterministicKey); + * ``` + */ + withSecretKey: (sk: Uint8Array): FungibleTokenPrivateState => { + 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 FungibleToken operations. + * @returns An object implementing the Witnesses interface for FungibleTokenPrivateState. + */ +export const FungibleTokenWitnesses = (): IFungibleTokenWitnesses< + L, + FungibleTokenPrivateState +> => ({ + wit_FungibleTokenSK( + context: WitnessContext, + ): [FungibleTokenPrivateState, Uint8Array] { + return [ + context.privateState, + Uint8Array.from(context.privateState.secretKey), + ]; + }, +}); diff --git a/contracts/src/token/witnesses/test/FungibleTokenWitnesses.test.ts b/contracts/src/token/witnesses/test/FungibleTokenWitnesses.test.ts new file mode 100644 index 00000000..b987ca03 --- /dev/null +++ b/contracts/src/token/witnesses/test/FungibleTokenWitnesses.test.ts @@ -0,0 +1,138 @@ +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import type { Ledger } from '../../../../artifacts/MockFungibleToken/contract/index.js'; +import { + FungibleTokenPrivateState, + FungibleTokenWitnesses, +} from '../FungibleTokenWitnesses.js'; + +const SECRET_KEY = new Uint8Array(32).fill(0x34); + +describe('FungibleTokenPrivateState', () => { + describe('generate', () => { + it('should return a state with a 32-byte secretKey', () => { + const state = FungibleTokenPrivateState.generate(); + expect(state.secretKey).toBeInstanceOf(Uint8Array); + expect(state.secretKey.length).toBe(32); + }); + + it('should produce unique secret key on successive calls', () => { + const a = FungibleTokenPrivateState.generate(); + const b = FungibleTokenPrivateState.generate(); + expect(a.secretKey).not.toEqual(b.secretKey); + }); + }); + + describe('withSecretKey', () => { + it('should accept a valid 32-byte secret key', () => { + const state = FungibleTokenPrivateState.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 = FungibleTokenPrivateState.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(() => FungibleTokenPrivateState.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(() => FungibleTokenPrivateState.withSecretKey(long)).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 64 bytes', + ); + }); + + it('should throw for an empty array', () => { + expect(() => + FungibleTokenPrivateState.withSecretKey(new Uint8Array(0)), + ).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 0 bytes', + ); + }); + }); +}); + +describe('FungibleTokenWitnesses', () => { + const witnesses = FungibleTokenWitnesses(); + + function makeContext( + privateState: FungibleTokenPrivateState, + ): WitnessContext { + return { privateState } as WitnessContext< + Ledger, + FungibleTokenPrivateState + >; + } + + describe('wit_FungibleTokenSK', () => { + it('should return a tuple of [privateState, secretKey]', () => { + const state = FungibleTokenPrivateState.withSecretKey(SECRET_KEY); + const ctx = makeContext(state); + + const [returnedState, returnedSK] = witnesses.wit_FungibleTokenSK(ctx); + + expect(returnedState).toBe(state); + expect(returnedSK).toEqual(SECRET_KEY); + }); + + it('should return the exact same privateState reference', () => { + const state = FungibleTokenPrivateState.generate(); + const ctx = makeContext(state); + + const [returnedState] = witnesses.wit_FungibleTokenSK(ctx); + expect(returnedState).toBe(state); + }); + + it('should return the secretKey as a Uint8Array', () => { + const state = FungibleTokenPrivateState.generate(); + const ctx = makeContext(state); + + const [, returnedSK] = witnesses.wit_FungibleTokenSK(ctx); + expect(returnedSK).toBeInstanceOf(Uint8Array); + expect(returnedSK.length).toBe(32); + }); + + it('should work with a randomly generated state', () => { + const state = FungibleTokenPrivateState.generate(); + const ctx = makeContext(state); + + const [returnedState, returnedSK] = witnesses.wit_FungibleTokenSK(ctx); + + expect(returnedState).toBe(state); + expect(returnedSK).toEqual(state.secretKey); + }); + }); +}); + +describe('FungibleTokenWitnesses factory', () => { + it('should return a fresh witnesses object on each call', () => { + const a = FungibleTokenWitnesses(); + const b = FungibleTokenWitnesses(); + expect(a).not.toBe(b); + }); + + it('should produce witnesses with identical behaviour', () => { + const a = FungibleTokenWitnesses(); + const b = FungibleTokenWitnesses(); + const state = FungibleTokenPrivateState.generate(); + const ctx = { privateState: state } as WitnessContext< + Ledger, + FungibleTokenPrivateState + >; + + const [stateA, skA] = a.wit_FungibleTokenSK(ctx); + const [stateB, skB] = b.wit_FungibleTokenSK(ctx); + + expect(stateA).toBe(stateB); + expect(skA).toEqual(skB); + }); +});