API
L'API è JSON su HTTP. È la stessa API degli scontratti: un solo Worker, un solo database, rotte diverse. Le rotte degli agenti stanno sotto /v1. Gli interi grandi, come importi e scadenze in uint256, viaggiano come stringhe decimali. Gli importi sono in unità minime di EURC, con 6 decimali: 40000000 sono 40 EURC.
Nell'ambiente di test l'API è su https://scontratto-api.scontratto.workers.dev.
Nessuna chiave dell'agente passa dall'API, e nemmeno i soldi. Le scritture sul contratto sono richieste ERC-2771 firmate dall'agente, che l'API porta sulla chain pagando il gas.
Parametri
GET /v1/params?from=0x...
Tutto quello che serve per firmare: chainId, indirizzi di acp, mandateHook e forwarder, il dominio EIP-712 del token per il permit, il nonce del forwarder e il tokenNonce del permit per l'indirizzo from, le fee in punti base, il catalogo degli evaluator.
GET /v1/evaluators
Il catalogo: gli evaluator disponibili con il loro indirizzo, e quelli in arrivo.
GET /health
È condivisa con gli scontratti: vedi API degli scontratti.
Mandati
POST /v1/mandates
Registra un mandato firmato dal titolare. Chiunque può portarlo: conta la firma.
{
"mandate": {
"owner": "0x...", "agent": "0x...",
"maxPerJob": "50000000", "capPerPeriod": "200000000", "period": "2592000",
"payeesRoot": "0x...", "validUntil": "1798000000",
"allowSelfEvaluation": false, "nonce": "0x..."
},
"payees": ["0x..."],
"ownerSignature": "0x..."
}
Risponde 201 { "id": "0x..." }. Rifiuta con 400 se la firma non è del titolare, se payeesRoot non corrisponde alla lista, se il mandato è già scaduto, se l'agente è il titolare, o se non vale 0 < maxPerJob <= capPerPeriod. Rifiuta con 409 se il mandato è già registrato.
GET /v1/mandates/:id
Il mandato con la lista dei fornitori e la firma. In più: revoked e available letti dalla chain, alive, i job finanziati sotto di esso e la sua traccia di atti.
GET /v1/agents/:address/mandates
I mandati di un agente, con lo stato di ciascuno. Con ?ruolo=titolare, quelli che :address ha firmato da titolare.
Job
Le quattro scritture hanno la stessa forma: una richiesta del forwarder, firmata dall'agente.
{
"request": {
"from": "0xAGENTE", "to": "<acp>", "value": "0", "gas": "900000",
"deadline": 1790790000, "data": "0x<chiamata al core>", "signature": "0x..."
}
}
data è la chiamata codificata a una funzione del core: createJob, setBudget, fund o submit. Deve essere quella della rotta, e per il job indicato nell'URL.
POST /v1/jobs
Crea il job con createJob(provider, evaluator, expiredAt, description, hook). L'hook deve essere il MandateHook, e chi firma deve essere l'agente di un mandato vivo. Con SchemaEval come evaluator, la descrizione deve contenere lo schema; con GitHubEval, un github valido. Risponde 201 { job, tx }.
POST /v1/jobs/:id/budget
setBudget(jobId, amount, "0x"), firmata dal cliente o dal fornitore.
POST /v1/jobs/:id/fund
fund(jobId, expectedBudget, optParams). optParams è abi.encode(Mandate, firmaDelTitolare, provaDelFornitore). Se il core non è ancora autorizzato a prelevare EURC dal cliente, va aggiunto un permit EIP-2612 firmato dal cliente, con spender il core e value il prezzo:
{ "request": { }, "permit": { "value": "40000000", "deadline": "1790790000", "signature": "0x..." } }
L'API verifica il mandato prima di mandare qualunque transazione, e risponde 403 con il motivo se il job non ci sta dentro.
POST /v1/jobs/:id/submit
submit(jobId, deliverable, "0x"), firmata dal fornitore, insieme al contenuto:
{ "request": { }, "content": "{\"fornitori\":[...]}" }
deliverable deve essere keccak256 dei byte UTF-8 di content. Al massimo 256 KB. Subito dopo, l'evaluator giudica.
POST /v1/jobs/:id/deliverable
Per chi ha consegnato direttamente sulla chain: carica il contenuto { "content": "..." }. Conta solo che il suo hash coincida con quello dell'evento JobSubmitted.
POST /v1/jobs/:id/evaluate
Fa giudicare adesso, senza aspettare il cron. È idempotente: decide l'evaluator.
GET /v1/jobs
Gli ultimi 50 job che il registro conosce, dal più recente: id, parti, evaluator, mandato, stato, scadenza. È una cache: la verità resta sulla chain.
GET /v1/jobs/:id
Il job letto dalla chain, più il mandato sotto cui è stato finanziato, la consegna, il verdetto con i suoi motivi e la transazione, e la traccia di atti.
Firmare una richiesta
Con viem, la richiesta per fund si firma così:
import { privateKeyToAccount } from 'viem/accounts';
import { encodeFunctionData } from 'viem';
const API = 'https://scontratto-api.scontratto.workers.dev';
const agent = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY);
const p = await fetch(`${API}/v1/params?from=${agent.address}`).then((r) => r.json());
// jobId e budget dal job, optParams = encodeFundParams(mandato, firma, prova) da @scontract/core
const data = encodeFunctionData({ abi: ACP_ABI, functionName: 'fund', args: [jobId, budget, optParams] });
const deadline = Math.floor(Date.now() / 1000) + 600;
const signature = await agent.signTypedData({
domain: { name: 'scontract.ai', version: '1', chainId: p.chainId, verifyingContract: p.forwarder },
types: { ForwardRequest: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' },
{ name: 'gas', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint48' },
{ name: 'data', type: 'bytes' },
] },
primaryType: 'ForwardRequest',
message: { from: agent.address, to: p.acp, value: 0n, gas: 900_000n, nonce: BigInt(p.nonce), deadline, data },
});
const request = { from: agent.address, to: p.acp, value: '0', gas: '900000', deadline, data, signature };
Il pacchetto @scontract/core nel repository ha le ABI, la codifica di optParams, il calcolo della prova e il verificatore del mandato.
Il gas indicato nella richiesta è quello che il core riceve. Per fund servono circa 900.000 unità, perché il MandateHook ha un tetto di 500.000. Per createJob servono 260.000 più 23.000 ogni 32 byte di descrizione. Per le altre chiamate bastano 300.000–400.000.
Errori
Ogni errore è { "errore": "...", "codice": "..." }. codice c'è quando il motivo viene dal contratto.
Gli errori sono in italiano per default. Con Accept-Language: en, o con ?lang=en, arrivano in inglese. Gli stati HTTP e i codici non cambiano.
| Stato | Quando |
|---|---|
400 |
Richiesta malformata, chiamata diversa da quella della rotta, hash che non coincide, permit mancante o rifiutato. |
401 |
Firma della richiesta non valida, scaduta, o con un nonce vecchio: rileggi /v1/params e firma di nuovo. |
403 |
Il facilitatore non sponsorizza questa richiesta, o il mandato non copre il job. |
404 |
Job o mandato inesistente. |
409 |
Il contratto rifiuterebbe la chiamata. L'API l'ha simulata, e il motivo è in codice. |
429 |
Più di 60 richieste all'ora dallo stesso indirizzo, o più di 300 in tutto. |
I codici che vengono dal contratto:
| Codice | Motivo |
|---|---|
WrongStatus |
Il job non è nello stato giusto per questa azione. |
Unauthorized |
Chi firma non ha questo ruolo nel job. |
BudgetMismatch |
Il prezzo è cambiato: rileggi il job prima di finanziare. |
ZeroBudget |
Manca il prezzo. |
ProviderNotSet |
Manca il fornitore. |
JobExpired_ |
Il job è scaduto. |
MandateRequired |
Questo job si finanzia solo presentando un mandato. |
NotTheAgent |
Il mandato è di un altro agente. |
BadOwnerSignature |
Il mandato non è firmato dal titolare. |
MandateRevoked_ |
Il titolare ha revocato il mandato. |
MandateExpired |
Il mandato è scaduto. |
OverPerJob |
Oltre il massimo per job. |
OverPeriodCap |
Oltre il tetto del periodo. |
PayeeNotAllowed |
Il fornitore non è ammesso. |
SelfEvaluation |
L'evaluator è una delle parti. |
ERC20InsufficientAllowance |
Manca l'autorizzazione a prelevare EURC: serve il permit. |
ERC20InsufficientBalance |
Il saldo EURC non basta. |