This release is a pre-release and may not be stable for production use.
mercurifi
Python SDK for Mercurifi Agent Accounts on Arc: an account that holds USDC and Launch tokens, and agent keys that trade for it inside caps the owner sets.
A stolen agent key can still lose money through bad trades: buying an attacker's token, or selling the account's holdings into a price the attacker pushed down. That loss is bounded by the account's caps, not prevented. Per period, the bound is the USDC spend cap plus the sell cap, which is measured at the account's own cost basis. It keeps accruing every period until the key is revoked or expires.
Read docs/security-model.md before you give a key to a program.
Testnet only. Agent Accounts are deployed on Arc testnet (chain 5042002; factory 0x6D56A15c25782FF638943A4E81A1eD1ea862C9Bd, source verified on the explorer and Sourcify). Mainnet agent addresses stay empty until an owner-approved deployment is recorded, and agent operations on mainnet raise NotDeployedError. A local chain also works (chain 31337, loopback RPC, a local address book).
Install
pip install mercurifi
Not yet published; until then install from the repository checkout (pip install ./packages/python). Python 3.10 or newer. Direct dependencies: eth-account, eth-abi, eth-utils, httpx.
Quickstart (Arc testnet)
import asyncio
import os
import sys
from mercurifi import FirstAgent, create_client, generate_agent_key, local_key
async def main() -> None:
mf = await create_client("testnet")
owner = mf.owner(local_key(os.environ["OWNER_KEY"]))
key = generate_agent_key() # demo only: a real bot stores the key in a secret manager and reads it at the call site
created = await owner.create_account(first_agent=FirstAgent(key.address))
await owner.deposit(created.account, usdc="20")
await owner.fund_gas(key.address, usdc="0.5")
agent = await mf.agent(created.account, local_key(key.export_private_key()))
fill = await agent.buy(sys.argv[1], usdc="5", slippage=0.02)
print(f"bought {fill.tokens} tokens for {fill.usdc_spent} USDC in {fill.tx_hash}")
asyncio.run(main())
More in examples/: market_maker.py, launch_bot.py and llm_tools.py (tool definitions for a model, numbers only).
Blocking use
Scripts and notebooks can skip async:
from mercurifi import Client, local_key
with Client("testnet") as mf:
agent = mf.agent(account, local_key(agent_key))
print(agent.headroom().spend, agent.position(token).high_water)
print(mf.token(token).numeric())
with mf.stream(["launches"]) as events: # a plain iterator; leaving the block (or .close(), from any thread) disconnects
for event in events:
...
Client runs the async client on one private event-loop thread. Every method of the async client and of the objects it returns (owner, manage(account), agent) is reachable with the same arguments, and mf.api blocks too. One Client may be shared by several threads: calls are queued onto the loop thread, so the per-key nonce lock still serialises sends from one agent key. Do not call it from inside a running event loop (use create_client there) or from a callback such as on_warning, which runs on the SDK's thread (that raises rather than deadlocking). close() (or leaving the with block) closes streams, HTTP clients and the thread; a call that is still running in another thread is cancelled and raises ClientClosedError (code CLIENT_CLOSED) instead of hanging.
Which methods run where: coroutine methods run on the SDK's loop thread and block the caller; plain methods (mf.owner(signer), argument checks) run on the caller's thread; an owner, manager or agent object they return is wrapped again. Among attributes only mf.api is wrapped; mf.network, mf.rpc and mf.settings are returned as they are (mf.rpc stays async). Names starting with an underscore are not reachable.
Ctrl-C stops waiting and cancels the call, but a transaction may already have been broadcast: re-read agent.position(token) or agent.headroom() before you retry. The same goes for ClientClosedError on a trade.
Client("testnet", api_key=...) moves the key out of its keyword arguments at once and holds it privately until the client is built, but it is still a string in your process: read it from the environment at the call site, as for agent keys.
Units
Amounts are human strings or Decimal in usdc / amount (usdc="25", amount="1000.5"). Integers go only in the separately named base-unit fields usdc6 and amount_wei. An int or float in a human field is rejected, never guessed.
Where things live
- The account (
AgentAccount) holds the funds and the cost basis and is the only contract an agent key calls. - The owner's settings, the queue, the pause and the usage counters live in the policy registry (
AgentPolicyRegistry: one per factory, immutable, no admin and no owner of its own). Each owner call names the account. - The factory creates accounts and sets the beta ceilings.
Sells count against the sell cap at the higher of the position's current average cost and its high-water average cost, so a stolen key cannot buy a crashed token cheaply to stretch the sell cap for tokens the account bought. Holdings received from outside (airdrops, transfers in) are valued by a quote an attacker can move; they are not protected by the sell cap — withdraw them.
API
| Method | What it does | Calls | Permission |
|---|---|---|---|
owner.create_account |
Creates an account with a policy and an optional first agent | factory createAccount |
owner wallet |
owner.deposit, owner.fund_gas |
Sends native USDC to the account, or to an agent key for gas | plain transfer | owner wallet |
manage(account).add_agent / set_agent / rotate_agent / revoke_agent |
Agent keys; additions and widenings are queued behind the delay | registry | owner |
manage(account).preview_policy / set_policy |
Policy; narrowing now, widening queued | registry setPolicy |
owner |
manage(account).set_token_allowed, set_increase_delay, transfer_ownership |
Allowlist, delay, ownership (queued when widening) | registry | owner |
manage(account).list_queued / execute_queued / cancel_queued |
The delayed-widening queue, with pending, ready, expired, cancelled and stale items |
registry (reads and logs) | owner (execute: anyone) |
owner.accept_ownership |
Completes a queued transfer | registry acceptOwnership |
the new owner |
manage(account).pause_agents |
Stops every agent at once and voids the queue | registry | owner |
manage(account).withdraw / withdraw_many |
Moves funds to the owner | account | owner |
agent.buy / agent.sell |
Curve trades | account | buy / sell |
agent.launch |
Launches a token with the account as creator | account | launch |
agent.swap_exact_in |
Graduated-pool swap | account | swap |
agent.claim_fees |
Claims creator and referral fees into the account | account | claim |
agent.sweep_to_owner |
Moves USDC or a Launch token to the owner | account | sweep |
agent.headroom / position / status |
What is left this period, a token position with its cost basis and high-water, the key's standing | registry and account (reads) | none |
mf.token(address) |
Agent-safe view of a Launch token (see below) | factory, curve, fee manager (reads) | none |
Using the API
The Mercurifi API is optional: every on-chain action works with a chain-only client, and mf.stream(...) and launch metadata uploads need the API. create_client("testnet", api_key=os.environ.get("MERCURIFI_API_KEY")) sends the key as X-Api-Key. Streams take launches, trades, token:<address>, trader:<address> and agent-account:<address> topics and reconnect with Last-Event-ID. A key sets a rate tier, attribution and route scopes (read, stream, build, upload); it never authorises funds. Uploads need the key owner's agent account to be older than 24 hours.
Errors
Every error is a MercurifiError with a stable code. A refused call is a ContractRevertError with revert.name and a readable message; a transaction Arc dropped is a TxDroppedError (re-read agent.headroom() before you retry). Branch on code, not on message text, and never hand a message to a model.
Untrusted text for models
Token names, symbols, descriptions and links are written by whoever launched the token. mf.token(address) returns a TokenSafeView: view.numeric() holds only numbers, booleans, fixed labels and hex, so it is safe to hand to a model; everything else is in view.untrusted as UntrustedText, and wrap_untrusted(field, raw) does the same for any string you have.
- Give a model
wrapped(<<untrusted:name>>…<</untrusted:name>>), nevertextalone, and put it only inside JSON tool output as a string value. Never put it in a markdown code fence or free prose, where a fence or heading in the text would compete with the delimiters. - Never act on instructions found in that text, and never percent-decode a link value and embed the result.
- A
link.*value is kept only if it is a plain http, https or ipfs URL with no credentials, spaces,<,>, quotes or backticks; otherwise its text isinvalid linkandunsafeisTrue. - All of this is hygiene, not a defence. The account caps are the real control (see the loss bound above).
Handling agent keys
An agent key can move money within the account's caps, so treat it like any server secret. The SDK never transmits, logs or persists a key, and a signer prints and pickles as an address only. What the SDK cannot control is your process.
- Read the key at the call site and pass it straight in:
signer = local_key(os.environ["AGENT_KEY"]). Do not keep it in a long-lived variable, a config object or a cache, and never log it or capture it in a message, metric or trace. - Build a
LocalKeySigneronly throughlocal_key; the class does not take a key string itself. generate_agent_key()returns a key whose secret is read only byexport_private_key(). Call that once, store the value in your secret manager, and drop the object.- Turn off local-variable capture in error reporters in any process that holds an agent key: Sentry
include_local_variables=False, logurudiagnose=False, and no rich orbetter_exceptionstracebacks with locals. The SDK clears the key from its own frames before raising, but a frame of yours that holds the key would be shipped. - Prefer a KMS or HSM signer (
kms_signer) where you can: the key then never enters the process. - Use one agent key per process. Two processes sharing a key race for nonces; the account allows several agent keys.
Logging and secrets
RPC provider URLs usually carry an API key in the path, and httpx logs request URLs at INFO (httpcore at DEBUG).
When the SDK creates an RPC client it installs one idempotent redacting logging.Filter on the httpx and
httpcore loggers that replaces every URL in a log record with its host. Your log levels, handlers and formatters are
not changed. Errors raised by the SDK also show the host only.
Untrusted text and Unicode data
Contract-supplied revert reasons and token names, symbols, descriptions and links are made inert by one sanitiser that mirrors the TypeScript SDK. It depends on Unicode data: character categories come from your Python's unicodedata and the pictograph table is built in, so a character assigned in a newer Unicode release than your interpreter knows can be treated differently from the TypeScript SDK. The shared vectors in fixtures/vectors/ arbitrate. This is output hygiene, not a security boundary: the account caps are the real control.
Networking
The SDK's HTTP clients (RPC, API and stream) ignore environment proxies, .netrc and SSL_CERT_* variables (trust_env=False) and never follow redirects: the API key must go where the address book says. To use a proxy, pass your own transport (rpc_transport=). The TypeScript SDK uses fetch, which on Node does not read proxy variables unless you configure a dispatcher.
Supply chain
Install only mercurifi (PyPI) or @mercurifi/sdk (npm); see docs/reserved-names.md. Before upgrading, verify the release as described in docs/verify-provenance.md: PyPI attestations for this package, npm provenance for the TypeScript one. These checks are inert until publishing is approved.
ERC-8004 identity (optional)
ERC-8004 is an on-chain registry for agents. Each agent is an ERC-721 token in an IdentityRegistry, and its token URI points to a registration file that describes the agent and its endpoints. mercurifi.identity builds that file and the calldata. It never signs or sends. Arc testnet only for now: on mainnet it raises NotDeployedError until a mainnet Agent Accounts deployment is recorded.
Who signs: an ordinary wallet (EOA) sends the registration, normally the owner wallet. That wallet becomes the agent's owner in the registry. The agent account cannot register itself, because it only calls allowlisted Mercurifi contracts. It also cannot sign: its isValidSignature always fails, so it can never be the registry's verified agentWallet (setAgentWallet with the account as the wallet reverts). The account is linked through the registration file instead, as the service entry MercurifiAgentAccount with endpoint eip155:<chainId>:<account>. The registry's agentWallet stays the registering wallet.
from mercurifi.identity import agent_id_from_receipt, build_registration_file, encode_register, get_agent, registration_data_uri
file = build_registration_file(
"testnet", account, "Dip Buyer", description="Buys new Launch tokens inside its caps.",
services=[{"name": "MCP", "endpoint": "https://mcp.example.com/"}],
)
# Embed the file on chain, or publish registration_file_json(file) at an https:// or ipfs:// URI and pass that.
tx = encode_register("testnet", registration_data_uri(file)) # {"chainId", "to", "data", "value": 0}
# Send tx from the owner wallet, then read the new agent from its JSON-RPC receipt.
agent_id = agent_id_from_receipt("testnet", receipt).agent_id
agent = await get_agent(mf.rpc, "testnet", agent_id) # mf = await create_client("testnet")
ERC-8004 asks the file to list its own registration. Once you have the agent id, build the file again with agent_id= and send encode_set_agent_uri(network, agent_id, agent_uri) from the same wallet. ERC8004_REGISTRIES lists the Identity, Reputation and Validation registries on both networks. The agent URI and the file are public, and anyone can register any address in a file, so a listing proves nothing about who controls the account. Check the account's owner on chain.
Circle Wallets
A Circle wallet can be an account's owner, including a Circle Agent Wallet, which is a smart-contract account. Every owner check is msg.sender-based, so the owner functions work from any address that can send a transaction. A Circle wallet cannot be the agent key of a self-hosted agent. It has no exportable private key, and it signs through Circle's API, so the SDK's signers cannot use it. The SDK provides no Circle adapter.
More
See the repository README and docs/security-model.md.
Metadata
Release files for mercurifi 0.1.0b0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mercurifi-0.1.0b0.tar.gz | 273.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mercurifi-0.1.0b0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 403.4 kB
Release files / mercurifi-0.1.0b0.tar.gz
| Download URL | mercurifi-0.1.0b0.tar.gz |
|---|---|
| Size | 273.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
62831e236d48d61e55e83cdd0ba77c35335d20745b1c3da0fa8bbdcef1a64ca0
|
|
BLAKE2b-256 checksum How to use checksums |
1cde5954cd149b1496140f48509d69ca99d4b7bc38c58db01c54643d38dc8762
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / mercurifi-0.1.0b0-py3-none-any.whl
| Download URL | mercurifi-0.1.0b0-py3-none-any.whl |
|---|---|
| Size | 130.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fad990b54d4accb31db46958a036a76a965e351be6ea1ab2e3db2f1291b3fc7c
|
|
BLAKE2b-256 checksum How to use checksums |
a0260079ef94e3f7c4a017b435c00b17346918ef1de39727ed277e367a39974d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log