Skip to main content
Use the Memo contract to add a memo to a USDC transfer on Arc Testnet. This tutorial shows the full flow with viem, ethers, Python, and curl. You will encode the inner USDC transfer, submit it with Memo.memo(...), decode the receipt, and query past memo events by memoId. To learn how the Memo contract preserves your wallet as the sender and orders its events, see Transaction memos.

Prerequisites

Before you begin, ensure that you’ve:
  • Installed Node.js v22+ for the TypeScript examples, or Python 3.10+ for the Python example.
  • Created an Arc Testnet wallet.
  • Funded the wallet with testnet USDC from the Circle Faucet.
  • Chosen a recipient address on Arc Testnet.

Step 1. Set up the project

Create a new project and install the dependencies for the client library you want to use:
mkdir arc-transaction-memo
cd arc-transaction-memo
npm init -y
npm install dotenv ethers tsx typescript viem
Create an .env file:
Shell
touch .env
Add your configuration:
.env
PRIVATE_KEY=YOUR_PRIVATE_KEY
RECIPIENT_ADDRESS=RECIPIENT_ADDRESS
RPC_URL=https://rpc.testnet.arc.network
Replace YOUR_PRIVATE_KEY with the 0x-prefixed private key for the funded wallet. Replace RECIPIENT_ADDRESS with the wallet that should receive USDC.

Step 2. Review the contract address and ABI

Arc Testnet uses the following predeployed transaction memo contracts: For the full address list, see contract addresses. Create memo-abi.json in your project:
memo-abi.json
[
  {
    "type": "function",
    "name": "memo",
    "stateMutability": "nonpayable",
    "inputs": [
      { "name": "target", "type": "address" },
      { "name": "data", "type": "bytes" },
      { "name": "memoId", "type": "bytes32" },
      { "name": "memoData", "type": "bytes" }
    ],
    "outputs": []
  },
  {
    "type": "event",
    "name": "BeforeMemo",
    "anonymous": false,
    "inputs": [{ "name": "memoIndex", "type": "uint256", "indexed": true }]
  },
  {
    "type": "event",
    "name": "Memo",
    "anonymous": false,
    "inputs": [
      { "name": "sender", "type": "address", "indexed": true },
      { "name": "target", "type": "address", "indexed": true },
      { "name": "callDataHash", "type": "bytes32", "indexed": false },
      { "name": "memoId", "type": "bytes32", "indexed": true },
      { "name": "memo", "type": "bytes", "indexed": false },
      { "name": "memoIndex", "type": "uint256", "indexed": false }
    ]
  }
]
The script you build in the next steps loads this ABI file from the project root.

Step 3. Configure the client connection

Create the script file for your client library. The first chunk reads the wallet and recipient configuration from .env, sets the contract addresses, loads the Memo ABI, and creates the clients that sign and send requests.
The example imports the arcTestnet chain definition from viem/chains, which requires viem v2.38 or later.Create viem-memo.ts:
TypeScript
import "dotenv/config";
import { readFileSync } from "node:fs";
import {
  type Abi,
  type Address,
  createPublicClient,
  createWalletClient,
  encodeFunctionData,
  erc20Abi,
  getAddress,
  http,
  keccak256,
  parseAbiItem,
  parseEventLogs,
  parseUnits,
  stringToHex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";

const rpcUrl = process.env.RPC_URL ?? "https://rpc.testnet.arc.network";
const privateKey = process.env.PRIVATE_KEY as `0x${string}`;
const recipient = getAddress(process.env.RECIPIENT_ADDRESS as Address);

const memoAddress = "0x5294E9927c3306DcBaDb03fe70b92e01cCede505";
const usdcAddress = "0x3600000000000000000000000000000000000000";
const memoAbi = JSON.parse(readFileSync("memo-abi.json", "utf8")) as Abi;

const account = privateKeyToAccount(privateKey);
const publicClient = createPublicClient({
  chain: arcTestnet,
  transport: http(rpcUrl),
});
const walletClient = createWalletClient({
  account,
  chain: arcTestnet,
  transport: http(rpcUrl),
});
The public client reads chain state, and the wallet client signs and submits transactions from your funded account.

Step 4. Encode the transfer and memo values

Add the values that define the memo transfer. The encoded ERC-20 transfer call becomes the inner call that Memo.memo(...) forwards to USDC. The memoId is a 32-byte identifier your application uses to look the memo up later, and the memo bytes carry the metadata itself.
Append to viem-memo.ts:
TypeScript
const transferData = encodeFunctionData({
  abi: erc20Abi,
  functionName: "transfer",
  args: [recipient, parseUnits("1", 6)],
});
const callDataHash = keccak256(transferData);
const memoId = keccak256(stringToHex("invoice-2026-0001"));
const memoBytes = stringToHex("order=2026-0001");
The transfer amount is 1 USDC, expressed with six decimals. The script also hashes the transfer calldata so a later step can match it against the callDataHash field of the emitted Memo event.

Step 5. Confirm the Memo contract is deployed

Before you send the transaction, check that the Memo address has deployed bytecode. If eth_getCode returns 0x, the contract is not available on the RPC endpoint you are using, and the script stops with an error.
Append to viem-memo.ts:
TypeScript
const memoCode = await publicClient.getCode({ address: memoAddress });
if (!memoCode || memoCode === "0x") {
  throw new Error(`Memo contract is not deployed at ${memoAddress}`);
}

Step 6. Send the memo transaction

Call Memo.memo(...) with the four arguments from Step 4: the USDC contract as the target, the encoded transfer calldata, the memoId, and the memo bytes. Then wait for the receipt and confirm the transaction succeeded.
Append to viem-memo.ts:
TypeScript
const hash = await walletClient.writeContract({
  address: memoAddress,
  abi: memoAbi,
  functionName: "memo",
  args: [usdcAddress, transferData, memoId, memoBytes],
});

const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
  throw new Error(`Memo transaction reverted: ${hash}`);
}

Step 7. Decode and verify the memo events

A successful memo transfer emits one BeforeMemo event and one Memo event alongside the USDC Transfer. Decode the receipt logs and verify that the Memo event carries the sender, target, calldata hash, memoId, and memo bytes the script sent.
Append to viem-memo.ts:
TypeScript
const events = parseEventLogs({
  abi: memoAbi,
  logs: receipt.logs,
});

const beforeMemoEvents = events.filter(
  (event) => event.eventName === "BeforeMemo",
);
const memoEvents = events.filter((event) => event.eventName === "Memo");
if (beforeMemoEvents.length !== 1 || memoEvents.length !== 1) {
  throw new Error("Expected exactly one BeforeMemo event and one Memo event");
}

const memoArgs = memoEvents[0].args as {
  sender: Address;
  target: Address;
  callDataHash: `0x${string}`;
  memoId: `0x${string}`;
  memo: `0x${string}`;
  memoIndex: bigint;
};
if (getAddress(memoArgs.sender) !== account.address) {
  throw new Error(`Unexpected memo sender: ${memoArgs.sender}`);
}
if (getAddress(memoArgs.target) !== getAddress(usdcAddress)) {
  throw new Error(`Unexpected memo target: ${memoArgs.target}`);
}
if (memoArgs.callDataHash !== callDataHash) {
  throw new Error(`Unexpected callDataHash: ${memoArgs.callDataHash}`);
}
if (memoArgs.memoId !== memoId || memoArgs.memo !== memoBytes) {
  throw new Error("Memo event did not include the expected memoId and memo");
}

console.log(
  "Transaction:",
  `${arcTestnet.blockExplorers.default.url}/tx/${hash}`,
);
console.log("Block:", receipt.blockNumber.toString());
console.log("Decoded memo events:", events);

Step 8. Query memo events by memoId

memoId is an indexed event field, so you can find the memo again later without the transaction hash. Query the Memo logs for the memoId the script sent and confirm exactly one match.
Append to viem-memo.ts:
TypeScript
const memoEvent = parseAbiItem(
  "event Memo(address indexed sender,address indexed target,bytes32 callDataHash,bytes32 indexed memoId,bytes memo,uint256 memoIndex)",
);
const matchingLogs = await publicClient.getLogs({
  address: memoAddress,
  event: memoEvent,
  args: { memoId },
  fromBlock: receipt.blockNumber,
  toBlock: receipt.blockNumber,
});
if (matchingLogs.length !== 1) {
  throw new Error(
    `Expected one Memo log for memoId, found ${matchingLogs.length}`,
  );
}

console.log("Memo events matching memoId:", matchingLogs);

Step 9. Run the script

Run the completed script for your client library:
npx tsx --env-file=.env viem-memo.ts
The exact formatting differs by client library, but successful output includes the transaction URL, block number, decoded BeforeMemo and Memo event data, and one historical log match for the same memoId:
Transaction: https://testnet.arcscan.app/tx/0x...
Block: 123456
BeforeMemo: { memoIndex: 42 }
Memo: {
  sender: "0xYourWallet...",
  target: "0x3600000000000000000000000000000000000000",
  callDataHash: "0x...",
  memoId: "0x...",
  memo: "0x...",
  memoIndex: 42
}
Memo events matching memoId: [ ... ]

Step 10. Check JSON-RPC directly with curl

Use curl for read-only JSON-RPC checks, such as verifying deployed bytecode, reading a transaction receipt, or querying Memo logs. Use a client library such as viem, ethers, or web3.py to sign and submit the transaction itself.
MEMO_ADDRESS=0x5294E9927c3306DcBaDb03fe70b92e01cCede505
RPC_URL=https://rpc.testnet.arc.network

curl --request POST "$RPC_URL" \
  --header "content-type: application/json" \
  --data '{
    "jsonrpc": "2.0",
    "method": "eth_getCode",
    "params": ["'"$MEMO_ADDRESS"'", "latest"],
    "id": 1
  }'
You can now attach memos to USDC transfers and reconcile them from the emitted events. For the event schema, nested memo behavior, and the guardrails that constrain memo calls, see Transaction memos.