The mandate
The mandate is the document with which the owner authorizes an agent to spend on their behalf. The owner signs it, the agent presents it at every funding, and the MandateHook enforces it on the chain. If the job does not fit within it, the funding does not happen and the reason comes back in readable form.
The fields
| Field | Type | What it means |
|---|---|---|
owner |
address |
The owner. Signs the mandate and answers for it. |
agent |
address |
The agent's key, which must be the client of the job. Never the same as the owner. |
maxPerJob |
uint256 |
The maximum for a single job, in EURC base units (6 decimals). |
capPerPeriod |
uint256 |
The spending cap per period. |
period |
uint64 |
The length of the period in seconds. Zero means a single period for the whole life of the mandate. |
payeesRoot |
bytes32 |
The Merkle root of the allowed providers. Zero means anyone. |
validUntil |
uint64 |
After this moment the mandate is no longer valid. |
allowSelfEvaluation |
bool |
If true, the agent can be the evaluator of its own jobs. |
nonce |
bytes32 |
Random: two mandates with the same terms stay distinct. |
The signature
The mandate is signed with EIP-712, in the MandateHook domain:
{
"domain": { "name": "scontract.ai", "version": "1", "chainId": 84532, "verifyingContract": "<MandateHook>" },
"types": {
"Mandate": [
{ "name": "owner", "type": "address" },
{ "name": "agent", "type": "address" },
{ "name": "maxPerJob", "type": "uint256" },
{ "name": "capPerPeriod", "type": "uint256" },
{ "name": "period", "type": "uint64" },
{ "name": "payeesRoot", "type": "bytes32" },
{ "name": "validUntil", "type": "uint64" },
{ "name": "allowSelfEvaluation", "type": "bool" },
{ "name": "nonce", "type": "bytes32" }
]
},
"primaryType": "Mandate"
}
The EIP-712 hash is the mandate id: the same in the API, in the registry and on the chain. A signature is valid for only one MandateHook on only one chain.
The owner can be a wallet or a smart account: the hook accepts ERC-1271 signatures, so a company can sign from its multisig.
Signing and registering it
For the pilot there is a CLI that signs with a key in an environment variable and registers the mandate:
OWNER_PRIVATE_KEY=0x... SCONTRACT_API_URL=https://scontratto-api.scontratto.workers.dev \
node apps/scontract-mcp/dist/mandato.mjs \
--agent 0xAGENT \
--per-job 50 --per-period 200 --period-days 30 \
--valid-days 90 \
--provider 0xPROVIDER_1 --provider 0xPROVIDER_2
| Option | Default | What it does |
|---|---|---|
--agent |
required | The agent's address. |
--per-job |
required | The per-job maximum, in EURC. |
--per-period |
required | The per-period cap, in EURC. It cannot be lower than the per-job maximum. |
--period-days |
30 |
The length of the period, in days. With 0, a single period. |
--valid-days |
90 |
How long the mandate lasts, in days. |
--provider |
none | An allowed provider. Repeatable. Without it, anyone is allowed. |
--self-evaluation |
off | The agent can be the evaluator of its own jobs. |
The Italian flags (--agente, --per-periodo, --giorni-periodo, --valido-giorni, --fornitore, --auto-valutazione) still work. Set SCONTRACT_LANG=en for English messages.
If you sign from your own wallet, you can build the mandate yourself and register it with POST /v1/mandates: see API.
Allowed providers
The plain-text list stays in the registry. Only its Merkle root goes on the chain. Each leaf is the keccak256 of the 20 bytes of the address, and each pair is hashed in ascending order, as OpenZeppelin's MerkleProof does. A node without a sibling moves up on its own. With a single provider, the root is its leaf and the proof is empty.
The agent attaches the proof for the job's provider to every funding. The MCP server computes it on its own from the registered list. The order of the list matters, because it is part of the signed document.
Periods
The period is a fixed window: timestamp / period. Everything the agent funds in one window adds up, and cannot exceed capPerPeriod. In the next window the count starts again from zero.
A job rejected by the evaluator frees its share of the cap immediately. An expired job frees it when someone calls MandateHook.release(jobId): the scontract.ai cron does this together with the refund. A completed job stays spent.
Revocation
The owner revokes by calling MandateHook.revoke(mandate) from their wallet, or from their smart account. This needs a little ETH for gas. From that moment no new job can be funded under that mandate. Jobs already funded follow their course.
What the hook checks
Before every fund, in order:
- The job's client is the mandate's agent.
- The signature is the owner's.
- The mandate is not revoked.
- The mandate has not expired.
- The price does not exceed the per-job maximum.
- With this price, the period does not exceed the cap.
- The provider is allowed.
- The evaluator is not the provider, and is not the agent unless the mandate allows it.
The API runs the same checks before sending the transaction, so a refusal costs zero gas and comes with its reason.
After Phase 0
Today the owner is a wallet. In v2 the mandate becomes the derived credential of Agent Rail: signed with a QES and rooted in the owner's CIE or EUDI Wallet. In v3 legal entities arrive, with signing powers from the Registro Imprese. The contract stays the same: what changes is who can sign.