Contracts
Three contracts, in packages/scontract-contracts, written with Foundry and OpenZeppelin 5.4.
| Contract | What it does |
|---|---|
AgenticCommerce |
The ERC-8183 core: jobs, escrow, fees. The funds sit here. |
MandateHook |
The hook that enforces the owner's mandate before every fund. |
ERC2771Forwarder |
OpenZeppelin's forwarder, named scontract.ai: it relays the requests signed by agents. |
The addresses for this environment are on the Test environment page.
AgenticCommerce
It follows the normative specification of ERC-8183. Where the reference implementation departs from it, it follows the specification. On top of that, and only on top of it, it has three things:
- ERC-2771 meta-transactions, with a forwarder fixed at deploy;
- fees fixed when the job is created, taken only on completion, with a 5% cap;
- a registry of allowed hooks, which does not affect jobs already created.
It is not upgradeable. There is an admin, who can only change the fees for future jobs, within the cap, and the hook registry. The admin cannot touch the funds or an open job. There is a single payment token, fixed at deploy: EURC.
Functions
| Function | Who | From which state |
|---|---|---|
createJob(provider, evaluator, expiredAt, description, hook) |
anyone, who becomes the client | n/a |
setProvider(jobId, provider, optParams) |
the client, if the provider is missing | Open |
setBudget(jobId, amount, optParams) |
client or provider | Open |
fund(jobId, expectedBudget, optParams) |
the client | Open, before expiry |
submit(jobId, deliverable, optParams) |
the provider | Funded, before expiry |
complete(jobId, reason, optParams) |
the evaluator | Submitted |
reject(jobId, reason, optParams) |
the client if Open, the evaluator if Funded or Submitted |
n/a |
claimRefund(jobId) |
anyone, after expiredAt |
Funded or Submitted |
getJob(jobId) |
view | n/a |
fund reverts if the price is zero, if the provider is missing, or if the price is not expectedBudget. complete pays the provider the price minus the two fees. reject and claimRefund return everything to the client.
Events
| Event | When |
|---|---|
JobCreated(jobId, client, provider, evaluator, expiredAt, hook) |
on creation |
ProviderSet(jobId, provider) |
when the provider is set |
BudgetSet(jobId, amount) |
on every proposed price |
JobFunded(jobId, client, amount) |
on funding |
JobSubmitted(jobId, provider, deliverable) |
on delivery |
JobCompleted(jobId, evaluator, reason) |
on completion |
JobRejected(jobId, rejector, reason) |
on rejection |
JobExpired(jobId) |
on refund after expiry |
PaymentReleased(jobId, provider, amount) |
payment to the provider |
EvaluatorFeePaid(jobId, evaluator, amount) |
fee to the evaluator |
PlatformFeePaid(jobId, treasury, amount) |
fee to the platform |
Refunded(jobId, client, amount) |
refund to the client |
Hooks
A hook is a contract that implements IACPHook:
interface IACPHook is IERC165 {
function beforeAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
function afterAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
}
The core calls it before and after setProvider, setBudget, fund, submit, complete and reject. It never calls it for claimRefund. Each call has a cap of 500,000 gas. If the hook fails, the action fails, with the hook's reason. data is encoded as in the standard's table:
| Function | data |
|---|---|
setProvider |
abi.encode(address provider, bytes optParams) |
setBudget |
abi.encode(uint256 amount, bytes optParams) |
fund |
optParams, as is |
submit |
abi.encode(bytes32 deliverable, bytes optParams) |
complete, reject |
abi.encode(bytes32 reason, bytes optParams) |
BaseACPHook routes these calls to named functions, such as _preFund or _postReject, and only accepts calls from the core.
MandateHook
| Function | What it does |
|---|---|
mandateId(m) |
The EIP-712 hash of the mandate. |
available(m) |
How much is left in the current period. |
spent(id, window) |
How much is committed in a window. |
windowOf(period, at) |
The window that a given moment falls in. |
revoked(id) |
Whether the owner has revoked it. |
uses(jobId) |
Under which mandate and in which window a job was funded. |
revoke(m) |
The owner revokes. |
release(jobId) |
Frees the cap held by an expired job. Anyone can call it. |
Before fund it reads the mandate from optParams, which is abi.encode(Mandate, bytes signature, bytes32[] proof), and applies the checks described in The mandate. After reject it frees the cap.
Events: MandateUsed(jobId, mandateId, owner, agent, amount), MandateReleased(jobId, mandateId, amount), MandateRevoked(mandateId, owner).
The forwarder
It is OpenZeppelin's ERC2771Forwarder, unmodified, with EIP-712 name scontract.ai and version 1. It verifies the signature, deadline and nonce of the request, then calls the core, appending the signer's address. The core reads it with _msgSender(). It only verifies ECDSA signatures: a smart account agent calls the core directly.
Deploy and test
cd packages/scontract-contracts
forge test
EURC=0x808456652fdb597867f38412077A9182bf77359F PLATFORM_FEE_BP=100 EVALUATOR_FEE_BP=50 \
forge script script/Deploy.s.sol:Deploy --rpc-url $RPC --private-key $DEPLOYER --broadcast
The script writes the addresses to deployments/<chainId>.json. Without EURC it creates a test EURC with permit, for anvil.
The contracts have not had an external audit yet. Until Phase 1 they are for the testnet only.