Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ SDK and platform feedback goes to [MultiAgency/platform](https://github.com/Mult
- **Setup (#12):** an account with no Treasury signs the server's setup text (a Treasury agent for its own Organization, good for an hour, for the platform's host); the server relays only that exact text to `/apps/v1/setup`, sending the same body once more if the answer is lost. The platform reserves the agent and stores a setup consent that lets the server relay only its creation: `agent_create`, naming the key that signed the setup, which the owner signs on the platform's confirm page; the page follows it by `owner/status`. A DAO sets up by its own vote instead (`daoSetups`, filed and stored like its consent), then votes on the creation, which must pass within minutes. `GET /api/setup` lists the agents the account's consents name, set up or created; the setup routes count against the account's requests a minute.
- **Proposals:** the owner lists and reads their agent's proposals, under their own Organization only. Approving or rejecting relays the owner's wallet signature over the SDK's `proposalText` of the proposal the page shows; the server reads it anew first and refuses, with nothing relayed, unless it's unexpired, still open and exactly that text, signed with a full-access key of the owner for the platform's host. Execute is sent only for an approved proposal, once per request, and never again after a lost answer: the proposal is read and answers `final`, `pending` (offer no Execute; read it again later) or `not_executable` (nothing was sent; it may be tried later). The platform claims a proposal once, so it never runs twice.
- **Owner reads (#61):** the agent's overview, activity, payouts held, operations without a proven outcome, and the autopilot's runs and one run's recording, each one platform read under the owner's consent. A proposal is found by its `requestId` among the agent's proposals (the platform has no lookup by it), and an operation's status by its `correlationId` from its activity row, never `owner/status`, which answers `unknown` for any swap. Every `bigint` the SDK reads goes out as its exact decimal string. No live consent is `not_connected`; any other failure, a thrown call included, is `unavailable`, and the platform's text stays in the log.
- **Audit trail:** every relayed setup, creation, consent, revocation, approval, rejection and execute is recorded in `TREASURY_AUDIT_FILE`, a JSON line before the platform is asked and one with its answer: who, what, the SHA-256 of the signed text, the platform's status and the code the browser got, never a signature. The server won't start if it can't append to the file or if others can read it.
- **Funding:** the agent's funding account (`treasury/get`) and its own account (`account`) are owner reads. A deposit address (`deposit`, USDC or NEAR as wNEAR) is asked for by anyone who may file proposals, and a wallet owner links, unlinks or marks the funding account confidential, each relayed once. A DAO agent's funding account is the DAO, fixed at setup: the server refuses a member's change (`dao_funding_fixed`) whatever their roles. The platform's refusal is `funding_refused`, its text logged.
- **Audit trail:** every relayed setup, creation, consent, revocation, approval, rejection, execute, deposit address and funding-account change is recorded in `TREASURY_AUDIT_FILE`, a JSON line before the platform is asked and one with its answer: who, what, the SHA-256 of the signed text, the platform's status and the code the browser got, never a signature. The server won't start if it can't append to the file or if others can read it.
- **The proposals page:** with `TREASURY_PAGE_DIR` set to the page's build (`web/dist-proposals`), the server serves the owner's proposals page from its own origin. It reads the build's regular files into memory at startup (no dotfiles or links) and serves them by exact path, for GET and HEAD: every other path outside `/api/` and `static/` is the page, which is never cached, may not be framed and sends no referrer. The page calls only this server's `/api/` routes.
- **`watch`, the proposer (#2):** with `TREASURY_WATCH_FILE` naming its agents, the server proposes a rebalance for each one when its holdings drift past the agent's threshold (5 points of the total by default), every `intervalS` (300 by default). It proposes only, with `legs: "strategy"`, and the owner approves on the proposals page. It holds while the agent has unresolved operations, while autopilot is on, while the policy isn't in force, and while a proposal of its own is still open. Each request is stored in `TREASURY_REQUESTS_FILE` before it's sent, and a lost answer is sent again unchanged, so it never makes a second proposal. If the owner's consent ends, the agent waits until they connect again. `notify`, a command and its arguments, runs once per proposal without a shell, with the proposal as JSON on stdin and only `PATH` in its environment. The server won't start if the agents' reads would spend more than half the platform's 1,200 calls an hour (3 a tick).
- **DAO-owned agents (#100):** a member of the DAO that owns an agent signs in as themselves, and is admitted by the DAO's policy (`get_policy`, read at most a minute ago) through the SDK's `accessOf`; each route also asks for what it needs (`mayCall`'s rules). The DAO's consent, and its verdict on a proposal, are filed for its vote (`daoConsents`, `daoVotes`), once per subject, stored before they're shown (`TREASURY_DAO_REQUESTS_FILE`); members file the platform's transaction from their own wallet or vote in Trezu. The server finds what the DAO already filed by matching its proposals' kind, never their description, and reads open requests every minute (at most five a pass, the least recently read first), so a vote that passed is completed in time. Filings are capped per member a day (`DAO_FILINGS_PER_DAY`).
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type AuditEntry = {
* result, for the DAO (`who`).
*/
action: "setup" | "create" | "consent" | "revoke" | "approve" | "reject" | "execute" | "dao-setup" | "dao-setup-reserved" | "dao-consent"
| "dao-consent-stored" | "dao-vote" | "dao-vote-relayed";
| "dao-consent-stored" | "dao-vote" | "dao-vote-relayed" | "deposit" | "link" | "unlink" | "confidential";
/**
* The agent the action concerns. A setup's attempt has none: the platform names the agent it reserves in its answer.
* `setup`: a wallet owner's signed setup text relayed (`subject`: the setup consent); `create`: an agent's creation
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,24 @@ export const overviewRead = ownerOutputs.overview.extend({
unmanaged: z.array(overviewShape.unmanaged.element.extend({ amount: rawAmount })),
});
export const activityRead = ownerOutputs.activity;
/** The agent's account: its wallet (the NEAR account that holds its balances), and the grants its executor holds. */
export const accountRead = ownerOutputs.account;
export const heldRead = ownerOutputs.held;
export const unresolvedRead = ownerOutputs.unresolved;
/** The autopilot's recorded runs, and one run's recording (the platform's strict recording format). */
export const runsRead = ownerOutputs["autopilot/evidence"];
export const recordingRead = RecordingSchema;
/** The account linked to fund the agent (`treasury/get`), or null: its balances are decimal strings already. */
export const treasuryRead = z.strictObject({ treasury: ownerOutputs["treasury/get"] });
/** A deposit address is asked for by asset: USDC, or NEAR, which funds the agent as wNEAR. */
export const depositRequest = z.strictObject({ asset: z.enum(["USDC", "NEAR"]) });
/** A one-time deposit address (`deposit`): pay it once, before it expires, with its memo if it names one. */
export const depositRead = z.strictObject({ deposit: ownerOutputs.deposit });
/** A wallet owner's changes to the agent's funding account. Each answers nothing; the page reads the account again. */
export const linkRequest = z.strictObject({ accountId });
export const unlinkRequest = z.strictObject({});
export const confidentialRequest = z.strictObject({ confidential: z.boolean() });
export const fundingChanged = z.strictObject({});
/** A proposal found by the `requestId` it was proposed with. */
export const requestRead = z.strictObject({ proposal: proposalView });
/**
Expand All @@ -174,7 +187,7 @@ export const operationRead = z.strictObject({ correlationId: z.string(), at: z.s
* `too_large`, `rate_limited`, `not_permitted` (a DAO member whose roles don't allow this), a sign-in code, or a consent code: `not_created`, `already_connected`, `not_this_consent`, `agent_changed`,
* `consent_refused`, a setup code: `not_this_setup`, `already_set_up`, `setup_unavailable`, `setup_refused`,
* `not_set_up`, `already_created`, `no_creation`, `not_a_dao`, a proposal code: `no_proposal`, `not_this_text`, `not_open`, `expired`, `not_approved`,
* `not_connected`, `signature_invalid`, or the platform's `invalidated`, `withdrawn`, `policy_not_in_force`, or a read's `not_found`);
* `not_connected`, `signature_invalid`, or the platform's `invalidated`, `withdrawn`, `policy_not_in_force`, a read's `not_found`, or a funding code: `dao_funding_fixed`, `funding_refused`);
* - `unavailable` (503): the platform or the chain didn't answer, or the server is misconfigured. Trying again later
* may work.
*/
Expand Down
68 changes: 65 additions & 3 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ type Reply = { status: number; body: contract.Answer<unknown>; cookie?: string }
* signs (`owner/prepare`: the owner, or a DAO member who may file its proposals, which the DAO contract checks too);
* `execute` asking for a proposal's execution.
*/
export type Need = "read" | "sign" | "file" | "execute";
const NEEDS: Record<Need, string> = { read: "proposals/get", sign: "owner/submit", file: "owner/prepare", execute: "proposals/execute" };
export type Need = "read" | "sign" | "file" | "execute" | "deposit" | "link";
const NEEDS: Record<Need, string> = { read: "proposals/get", sign: "owner/submit", file: "owner/prepare", execute: "proposals/execute",
deposit: "deposit", link: "treasury/link" };
type Route = { method: "GET" | "POST" | "DELETE"; path: string; handle(call: Call, context: Context): Promise<Reply> }
& ({ access: "public" } | { access: "session" } | { access: "owner" | "owner-fresh"; needs: Need });

Expand Down Expand Up @@ -155,7 +156,7 @@ function readFailure(answer: { ok: false; kind: string }, agent: string, read: s
/** An ID from the address for a read: a run's tick, a proposal's `requestId`, an operation's `correlationId`. */
const readId = (value: string | undefined) => value !== undefined && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : undefined;
/** A route answering one of the agent's owner reads as the platform answers it (#61). */
const readRoute = (path: string, read: "overview" | "activity" | "held" | "unresolved" | "runs"): Route => ({
const readRoute = (path: string, read: "account" | "overview" | "activity" | "held" | "unresolved" | "runs"): Route => ({
method: "GET", path, access: "owner", needs: "read",
async handle({ identity }, { platform }) {
const { agent, owner } = identity!;
Expand All @@ -165,6 +166,34 @@ const readRoute = (path: string, read: "overview" | "activity" | "held" | "unres
},
});

/** What a deposit address is asked for by: the asset the page names, as the platform takes it. NEAR is wrapped, so it funds wNEAR. */
const DEPOSIT_ASSETS = { USDC: "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", NEAR: "nep141:wrap.near" } as const;
/**
* A wallet owner's change to the agent's funding account, relayed once and recorded before the platform is asked and
* after: link an account, unlink it, or say whether it's a confidential DAO. A DAO agent's funding account is the DAO,
* fixed at setup, and the platform refuses these under a DAO's consent: a member is refused here (`dao_funding_fixed`)
* whatever their roles, since one with every DAO permission passes `mayCall`. The platform's refusal is
* `funding_refused` (its text is logged, never forwarded); after a lost answer the page reads the account again before
* anything else.
*/
const fundingChange = <B extends z.ZodType>(path: string, action: "link" | "unlink" | "confidential", body: B,
change: (platform: Platform, organization: string, agent: string, input: z.output<B>) => Promise<{ ok: true } | { ok: false; kind: string; status?: number }>): Route => ({
method: "POST", path, access: "owner-fresh", needs: "link",
async handle({ body: sent, account, identity }, { platform, audit }) {
const { agent, owner, signer } = identity!;
if (signer === "dao") return refused(409, "dao_funding_fixed");
const input = parse(body, sent);
if (input === undefined) return refused(400, "bad_request");
await audit.append({ who: account!, action, agent, stage: "attempt" });
const answer = await relay(() => change(platform, organizationOf(owner), agent, input), agent);
const reply = answer.ok ? ok({}) : answer.kind === "forbidden" ? refused(409, "not_connected")
: answer.kind === "refused" ? refused(409, "funding_refused") : unavailable();
if (!answer.ok && answer.kind !== "forbidden") log("platform_funding_failed", { agent, action, ...failure(answer) });
await audit.append({ who: account!, action, agent, stage: "result", answer: audited(answer, reply) });
return reply;
},
});

/** A platform call whose throw is a lost answer: logged, and answered as `unavailable`, so a result is always recorded. */
async function relay<Answer>(call: () => Promise<Answer>, agent: string): Promise<Answer | { ok: false; kind: "unavailable" }> {
try { return await call(); } catch (error) { log("platform_call_threw", { agent, error: String(error) }); return { ok: false, kind: "unavailable" }; }
Expand Down Expand Up @@ -630,11 +659,44 @@ export const routes: Route[] = [
return ok({ proposal: read.value, ...(identity!.signer === "dao" && { vote: waiting ? await daoView(waiting, identity!, context) : null }) });
},
},
readRoute("/api/agents/:agent/account", "account"),
readRoute("/api/agents/:agent/overview", "overview"),
readRoute("/api/agents/:agent/activity", "activity"),
readRoute("/api/agents/:agent/held", "held"),
readRoute("/api/agents/:agent/unresolved", "unresolved"),
readRoute("/api/agents/:agent/runs", "runs"),
{
// The account linked to fund the agent, with its balances and, for a DAO, its members: null when none is linked.
method: "GET", path: "/api/agents/:agent/treasury", access: "owner", needs: "read",
async handle({ identity }, { platform }) {
const { agent, owner } = identity!;
const answer = await relay(() => platform.funding.treasury(organizationOf(owner), agent), agent);
return answer.ok ? ok({ treasury: answer.value }) : readFailure(answer, agent, "treasury/get");
},
},
{
/**
* A fresh one-time deposit address for USDC, or for NEAR (wrapped, so wNEAR). Asking again gives another address,
* so a lost answer is safe to ask again for; an address is never reused. Recorded before the platform is asked and after.
*/
method: "POST", path: "/api/agents/:agent/deposit", access: "owner-fresh", needs: "deposit",
async handle({ body, account, identity }, { platform, audit }) {
const { agent, owner } = identity!;
const input = parse(contract.depositRequest, body);
if (input === undefined) return refused(400, "bad_request");
await audit.append({ who: account!, action: "deposit", agent, stage: "attempt" });
const answer = await relay(() => platform.funding.deposit(organizationOf(owner), agent, DEPOSIT_ASSETS[input.asset]), agent);
const reply = answer.ok ? ok({ deposit: answer.value }) : readFailure(answer, agent, "deposit");
await audit.append({ who: account!, action: "deposit", agent, subject: answer.ok ? answer.value.correlationId : undefined, stage: "result", answer: audited(answer, reply) });
return reply;
},
},
fundingChange("/api/agents/:agent/treasury/link", "link", contract.linkRequest,
(platform, organization, agent, { accountId }) => platform.funding.link(organization, agent, accountId)),
fundingChange("/api/agents/:agent/treasury/unlink", "unlink", contract.unlinkRequest,
(platform, organization, agent) => platform.funding.unlink(organization, agent)),
fundingChange("/api/agents/:agent/treasury/confidential", "confidential", contract.confidentialRequest,
(platform, organization, agent, { confidential }) => platform.funding.setConfidential(organization, agent, confidential)),
{
// One recorded run of the autopilot, by its tick ID (`runs` lists them).
method: "GET", path: "/api/agents/:agent/runs/:id", access: "owner", needs: "read",
Expand Down
Loading
Loading