@@ -60,6 +60,7 @@ import { VaultDeployerContract, VaultDeployerContractArtifact } from '../../../s
6060import { NFTContract } from '../../../src/artifacts/NFT.js' ;
6161import { TestLogicContract } from '../../../src/artifacts/TestLogic.js' ;
6262import { EscrowContract } from '../../../src/artifacts/Escrow.js' ;
63+ import { MultiTokenContract } from '../../../src/artifacts/MultiToken.js' ;
6364
6465import { expect } from 'vitest' ;
6566
@@ -766,3 +767,183 @@ export async function expectNFTTransferEvents(
766767 expect ( events [ i ] . token_id ) . toEqual ( expected [ i ] . token_id ) ;
767768 }
768769}
770+
771+ // --- MultiToken Utils ---
772+ //
773+ // MultiToken is token-shaped (public + private balances, commitments, ARC-403 authwit hook) but
774+ // NFT-shaped in that every balance-bearing op carries an `id: Field`. Its event is the 4-field
775+ // `TransferSingle{from,to,id,amount}` (eventSelector 0x2429b477) — the existing 3-field `Transfer`
776+ // helpers cannot be reused (see the design-contract E-01 and the plan's divergence guard).
777+
778+ /** Human-readable name/symbol used by the MultiToken deploy helper (kept as constants so tests can round-trip them). */
779+ export const MULTITOKEN_NAME = 'MultiToken' ;
780+ export const MULTITOKEN_SYMBOL = 'MTK' ;
781+
782+ /** Token-id fixture used across the MultiToken happy-path tests. */
783+ export const ID_A = 1n ;
784+
785+ /**
786+ * Packs a short (<=31 byte) ASCII string into a single Field, big-endian — the encoding used by the
787+ * Noir `FieldCompressedString` that MultiToken's ctor takes for name/symbol (codegen shape `{ value }`).
788+ * The MultiToken ctor stores exactly the Field it is given, so the deploy-and-read-back round-trip in
789+ * tests holds regardless of any subtle byte-order difference; a real string is used so the value is meaningful.
790+ * @param s - The string to pack (max 31 bytes).
791+ * @returns The packed Field.
792+ */
793+ export function fieldFromShortString ( s : string ) : Fr {
794+ const bytes = new TextEncoder ( ) . encode ( s ) ;
795+ if ( bytes . length > 31 ) {
796+ throw new Error ( `String "${ s } " is too long to pack into a single Field (max 31 bytes)` ) ;
797+ }
798+ let acc = 0n ;
799+ for ( const b of bytes ) {
800+ acc = ( acc << 8n ) + BigInt ( b ) ;
801+ }
802+ return new Fr ( acc ) ;
803+ }
804+
805+ /**
806+ * Normalises the value of a decoded `FieldCompressedString` (returned by `name()`/`symbol()`) to a bigint,
807+ * tolerating whether the SDK decodes the struct to `{ value }` (bigint or Fr) or to a bare scalar.
808+ */
809+ export function compressedStringToBigInt ( result : any ) : bigint {
810+ const v = result ?. value ?? result ;
811+ if ( typeof v === 'bigint' ) return v ;
812+ if ( typeof v === 'number' ) return BigInt ( v ) ;
813+ return v . toBigInt ( ) ;
814+ }
815+
816+ /**
817+ * Deploys the MultiToken contract with a specified minter (and optional ARC-403 auth contract).
818+ * @param wallet - The wallet to deploy the contract with.
819+ * @param deployer - The account that sends the deploy tx.
820+ * @param minter - The address stored as the (immutable) minter.
821+ * @param authContract - Optional ARC-403 hook contract address; `AztecAddress.ZERO` (default) disables the hook.
822+ * @returns A deployed MultiTokenContract instance.
823+ */
824+ export async function deployMultiTokenWithMinter (
825+ wallet : Wallet ,
826+ deployer : AztecAddress ,
827+ minter : AztecAddress ,
828+ authContract : AztecAddress = AztecAddress . ZERO ,
829+ options ?: DeployOptions ,
830+ ) : Promise < MultiTokenContract > {
831+ const { contract } = await MultiTokenContract . deployWithOpts (
832+ { method : 'constructor_with_minter' , wallet } ,
833+ { value : fieldFromShortString ( MULTITOKEN_NAME ) } ,
834+ { value : fieldFromShortString ( MULTITOKEN_SYMBOL ) } ,
835+ minter ,
836+ authContract ,
837+ ) . send ( { ...options , from : deployer } ) ;
838+ return contract as MultiTokenContract ;
839+ }
840+
841+ // TODO: Replace wallet internals (privateExecutionResult) with simulate() + send() to get private return values via public API.
842+ /**
843+ * Initializes a MultiToken transfer commitment (partial note) and returns its commitment Field.
844+ * Mirrors `initializeTransferCommitment` (the ONLY sanctioned wallet-internals escape hatch) — the
845+ * MultiToken `initialize_transfer_commitment(to, completer)` is id-AGNOSTIC (the completer binds the id
846+ * at completion), so the signature is identical to the Token/NFT variant. Reaches into
847+ * `WalletWithInternals` to extract the partial-note commitment from `provenTx.privateExecutionResult`.
848+ * @param token - The MultiToken contract instance.
849+ * @param caller - The account that sends (and settles) the initialize tx.
850+ * @param to - The address of the note recipient.
851+ * @param completer - The address allowed to complete the partial note.
852+ * @returns Partial note commitment.
853+ */
854+ export async function initializeMultiTokenTransferCommitment (
855+ token : MultiTokenContract ,
856+ caller : AztecAddress ,
857+ to : AztecAddress ,
858+ completer : AztecAddress ,
859+ ) : Promise < bigint > {
860+ const interaction = token . methods . initialize_transfer_commitment ( to , completer ) ;
861+ const executionPayload = await interaction . request ( ) ;
862+ const w = token . wallet as unknown as WalletWithInternals ;
863+ const feeOptions = await w . completeFeeOptions ( caller , executionPayload . feePayer , undefined ) ;
864+ const txRequest = await w . createTxExecutionRequestFromPayloadAndFee ( executionPayload , caller , feeOptions ) ;
865+ const provenTx = await w . pxe . proveTx ( txRequest , { scopes : w . scopesFrom ( caller ) , senderForTags : caller } ) ;
866+
867+ const entrypoint = provenTx . privateExecutionResult . entrypoint ;
868+ const nestedResults = entrypoint . nestedExecutionResults ;
869+ const returnValues = nestedResults [ 0 ] . returnValues ;
870+ const commitment = returnValues [ 0 ] . toBigInt ( ) ;
871+
872+ const tx = await provenTx . toTx ( ) ;
873+ const txHash = tx . getTxHash ( ) ;
874+ await node . sendTx ( tx ) ;
875+ await waitForTx ( node , txHash ) ;
876+
877+ return commitment ;
878+ }
879+
880+ // --- MultiToken Transfer Event Utils ---
881+
882+ /** Represents a decoded MultiToken TransferSingle event (4 fields: from, to, id, amount). */
883+ export type MultiTokenTransferEvent = {
884+ from : AztecAddress ;
885+ to : AztecAddress ;
886+ id : bigint ;
887+ amount : bigint ;
888+ } ;
889+
890+ /**
891+ * Queries the node for public logs emitted in a transaction by a specific MultiToken contract,
892+ * and decodes them as `TransferSingle` events (4 fields; `id` distinguishes it from the 3-field
893+ * Token/NFT `Transfer`). An empty array serves the "no public events" privacy assertions.
894+ *
895+ * @param txHash - The transaction hash to query logs for.
896+ * @param contractAddress - The MultiToken contract address to filter logs by.
897+ * @returns An array of decoded MultiTokenTransferEvent objects.
898+ */
899+ export async function getMultiTokenTransferEvents (
900+ txHash : TxHash ,
901+ contractAddress : AztecAddress ,
902+ ) : Promise < MultiTokenTransferEvent [ ] > {
903+ const response = await node . getPublicLogs ( {
904+ txHash,
905+ contractAddress,
906+ } ) ;
907+
908+ const eventMetadata = MultiTokenContract . events . TransferSingle ;
909+ const expectedFieldCount = 4 ; // from, to, id, amount
910+
911+ return response . logs
912+ . filter ( ( extLog ) => {
913+ const eventFields = extLog . log . getEmittedFieldsWithoutTag ( ) ;
914+ return eventFields . length === expectedFieldCount ;
915+ } )
916+ . map ( ( extLog ) => {
917+ const eventFields = extLog . log . getEmittedFieldsWithoutTag ( ) ;
918+ return decodeFromAbi ( [ eventMetadata . abiType ] , eventFields ) as MultiTokenTransferEvent ;
919+ } ) ;
920+ }
921+
922+ /**
923+ * Asserts that the TransferSingle events emitted by a specific MultiToken contract in a transaction
924+ * match the expected events exactly (count and content, order-sensitive).
925+ *
926+ * Comment convention above expectMultiTokenTransferEvents calls: `operation: TransferSingle(from, to, id, amount)`
927+ * - Mint to public: `// mint_to_public: TransferSingle(0x0, alice, id, AMOUNT)`
928+ * - Mint to commitment:`// mint_to_commitment: TransferSingle(0x0, PRIVATE, id, AMOUNT)`
929+ * - No events: `// transfer_private_to_private: (no public events)`
930+ *
931+ * @param txHash - The transaction hash to query logs for.
932+ * @param contractAddress - The MultiToken contract address to filter logs by.
933+ * @param expected - The expected TransferSingle events in order.
934+ */
935+ export async function expectMultiTokenTransferEvents (
936+ txHash : TxHash ,
937+ contractAddress : AztecAddress ,
938+ expected : MultiTokenTransferEvent [ ] ,
939+ ) : Promise < void > {
940+ const events = await getMultiTokenTransferEvents ( txHash , contractAddress ) ;
941+
942+ expect ( events . length ) . toBe ( expected . length ) ;
943+ for ( let i = 0 ; i < expected . length ; i ++ ) {
944+ expect ( events [ i ] . from ) . toEqual ( expected [ i ] . from ) ;
945+ expect ( events [ i ] . to ) . toEqual ( expected [ i ] . to ) ;
946+ expect ( events [ i ] . id ) . toEqual ( expected [ i ] . id ) ;
947+ expect ( events [ i ] . amount ) . toEqual ( expected [ i ] . amount ) ;
948+ }
949+ }
0 commit comments