diff --git a/README.md b/README.md index d3501a2..ef608a8 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/apps/server/src/audit.ts b/apps/server/src/audit.ts index 7965e67..6af7c3f 100644 --- a/apps/server/src/audit.ts +++ b/apps/server/src/audit.ts @@ -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 diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts index 035772e..677cdd2 100644 --- a/apps/server/src/contract.ts +++ b/apps/server/src/contract.ts @@ -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 }); /** @@ -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. */ diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7e8f0b4..42325bb 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -86,8 +86,9 @@ type Reply = { status: number; body: contract.Answer; 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 = { read: "proposals/get", sign: "owner/submit", file: "owner/prepare", execute: "proposals/execute" }; +export type Need = "read" | "sign" | "file" | "execute" | "deposit" | "link"; +const NEEDS: Record = { 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 } & ({ access: "public" } | { access: "session" } | { access: "owner" | "owner-fresh"; needs: Need }); @@ -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!; @@ -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 = (path: string, action: "link" | "unlink" | "confidential", body: B, + change: (platform: Platform, organization: string, agent: string, input: z.output) => 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(call: () => Promise, agent: string): Promise { try { return await call(); } catch (error) { log("platform_call_threw", { agent, error: String(error) }); return { ok: false, kind: "unavailable" }; } @@ -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", diff --git a/apps/server/src/platform.ts b/apps/server/src/platform.ts index aa84c3d..b0b528a 100644 --- a/apps/server/src/platform.ts +++ b/apps/server/src/platform.ts @@ -20,7 +20,11 @@ export type PreparedAnswer = OwnerResult>; /** Where an owner action stands: submitted, still voting, expired or failed. */ export type StatusAnswer = OwnerResult>; /** An owner read as the SDK answers it: its parsed output, `bigint` amounts included, or the failure's kind. */ -export type ReadAnswer

= OwnerResult>; +export type ReadAnswer

= OwnerResult>; +/** A call that funds the agent, or changes its funding account, as the SDK answers it. */ +export type FundingAnswer

= OwnerResult>; +/** The assets a deposit address takes: USDC, or wNEAR, which NEAR is wrapped into to fund it. */ +export type DepositAsset = "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" | "nep141:wrap.near"; /** An identity answer, and whether it was reused rather than read from the platform for this request. */ export type IdentityRead = { answer: IdentityAnswer; cached: boolean }; @@ -103,11 +107,13 @@ export type Platform = { status(organization: string, agent: string, correlationId: string): Promise; }; /** - * The agent's owner reads under an Organization's consent (#61): its holdings and policy, its activity, payouts held - * for approval, operations whose outcome isn't proven, and the autopilot's recorded runs and one run's recording. + * The agent's owner reads under an Organization's consent: its account (its wallet and the grants its executor + * holds), its holdings and policy, its activity, payouts held for approval, operations whose outcome isn't proven, + * and the autopilot's recorded runs and one run's recording. * Reads only: nothing here sends anything. */ reads: { + account(organization: string, agent: string): Promise>; overview(organization: string, agent: string): Promise>; activity(organization: string, agent: string): Promise>; held(organization: string, agent: string): Promise>; @@ -115,6 +121,18 @@ export type Platform = { runs(organization: string, agent: string): Promise>; recording(organization: string, agent: string, tickId: string): Promise>; }; + /** + * Funding the agent under an Organization's consent (its `treasury` scope): the account linked to fund it, a one-time + * deposit address, and a wallet owner's link to that account. A DAO's consent is refused the link and its setting: + * a DAO agent's funding account is the DAO, fixed at setup. + */ + funding: { + treasury(organization: string, agent: string): Promise>; + deposit(organization: string, agent: string, asset: DepositAsset): Promise>; + link(organization: string, agent: string, accountId: string): Promise>; + unlink(organization: string, agent: string): Promise>; + setConfidential(organization: string, agent: string, confidential: boolean): Promise>; + }; }; @@ -203,6 +221,7 @@ export function createPlatform({ origin, credential, timeoutMs, cacheMs = 60_000 status: async (organization, agent, correlationId) => (await client()).agent(organization, agent).call("owner/status", { correlationId }), }, reads: { + account: async (organization, agent) => (await client()).agent(organization, agent).call("account"), overview: async (organization, agent) => (await client()).agent(organization, agent).call("overview"), activity: async (organization, agent) => (await client()).agent(organization, agent).call("activity"), held: async (organization, agent) => (await client()).agent(organization, agent).call("held"), @@ -210,6 +229,13 @@ export function createPlatform({ origin, credential, timeoutMs, cacheMs = 60_000 runs: async (organization, agent) => (await client()).agent(organization, agent).call("autopilot/evidence", {}), recording: async (organization, agent, tickId) => (await client()).agent(organization, agent).call("autopilot/log", { tickId }), }, + funding: { + treasury: async (organization, agent) => (await client()).agent(organization, agent).call("treasury/get"), + deposit: async (organization, agent, asset) => (await client()).agent(organization, agent).call("deposit", { asset }), + link: async (organization, agent, accountId) => (await client()).agent(organization, agent).call("treasury/link", { accountId }), + unlink: async (organization, agent) => (await client()).agent(organization, agent).call("treasury/unlink"), + setConfidential: async (organization, agent, confidential) => (await client()).agent(organization, agent).call("treasury/setConfidential", { confidential }), + }, watch: { overview: async (organization, agent) => (await client()).agent(organization, agent).call("overview"), unresolved: async (organization, agent) => (await client()).agent(organization, agent).call("unresolved"), diff --git a/apps/server/test/dao-stack.e2e.ts b/apps/server/test/dao-stack.e2e.ts index 4bbcdbf..fd27cfc 100644 --- a/apps/server/test/dao-stack.e2e.ts +++ b/apps/server/test/dao-stack.e2e.ts @@ -140,3 +140,15 @@ test("the DAO approves one proposal and rejects another: a read relays the first assert.ok(read.ok, JSON.stringify(read)); assert.equal((read.value as ProposalView).state, "rejected", "relayed with nobody reading the page"); }); + +test("the DAO's council reads the DAO as the funding account and gets a deposit address; no member links another account", async () => { + const cookie = await signIn(council); + const read = await call("GET", `${agentPath()}/treasury`, cookie); + assert.equal(read.status, 200, JSON.stringify(read.body)); + assert.equal(contract.treasuryRead.parse(read.body.value).treasury?.accountId, dao, "fixed at setup"); + const deposit = await call("POST", `${agentPath()}/deposit`, cookie, { asset: "USDC" }); + assert.equal(deposit.status, 200, JSON.stringify(deposit.body)); + contract.depositRead.parse(deposit.body.value); + assert.deepEqual((await call("POST", `${agentPath()}/treasury/link`, cookie, { accountId: council.accountId })).body, + { ok: false, kind: "refused", code: "dao_funding_fixed" }, "the stack's council has every DAO permission"); +}); diff --git a/apps/server/test/fakes.ts b/apps/server/test/fakes.ts index 5c65f4c..5403587 100644 --- a/apps/server/test/fakes.ts +++ b/apps/server/test/fakes.ts @@ -73,6 +73,7 @@ export function fakePlatform(owners: Record, { failing = {}, for * `readAnswers[name]` queues answers given before it; an Error is thrown, as a broken call would. */ const reads: { [K in ReadName]: ReadValue } = { + account: { agentId: "id-portfolio", executor: true, wallet: "portfolio.agents.near", grant: null, stagedGrant: null }, overview: OVERVIEW, activity: [], held: [], unresolved: [], runs: { ticks: [], corrupt: [] }, recording: undefined, }; const readAnswers: { [K in ReadName]?: (ReadAnswer> | Error)[] } = {}; @@ -84,6 +85,20 @@ export function fakePlatform(owners: Record, { failing = {}, for const value = reads[name]; return value === undefined ? { ok: false, kind: "refused", status: 404, error: "No such run" } as never : { ok: true, value } as never; }; + /** + * Funding as the platform keeps it: the account each agent's owner linked to fund it (none until linked), and the + * deposit addresses it gave, each fresh. `fundingAnswers` queues answers a funding call gives instead, by call; an + * Error is thrown, as a broken call would. + */ + const treasuries: Record = {}; + const deposits: { agent: string; asset: string; address: string }[] = []; + const fundingAnswers: Partial> = {}; + const funding = (name: keyof typeof fundingAnswers, organization: string, agent: string, rest: string[], value: () => T) => { + calls.push([`funding.${name}`, organization, agent, ...rest].join(" ")); + const queued = fundingAnswers[name]?.shift(); + if (queued instanceof Error) throw queued; + return (queued ?? { ok: true, value: value() }) as never; + }; /** Proposals as the platform keeps them, under the Organization that made them. */ const proposals: (ProposalView & { agent: string })[] = []; /** Answers the next proposal actions give instead of their own, by action; an Error is thrown, as a broken call would. */ @@ -278,6 +293,7 @@ export function fakePlatform(owners: Record, { failing = {}, for }, }, reads: { + account: (organization, agent) => read("account", organization, agent), overview: (organization, agent) => read("overview", organization, agent), activity: (organization, agent) => read("activity", organization, agent), held: (organization, agent) => read("held", organization, agent), @@ -285,6 +301,22 @@ export function fakePlatform(owners: Record, { failing = {}, for runs: (organization, agent) => read("runs", organization, agent), recording: (organization, agent, tickId) => read("recording", organization, agent, tickId), }, + funding: { + treasury: async (organization, agent) => funding("treasury", organization, agent, [], () => { + const linked = treasuries[agent]; + return linked ? { accountId: linked.accountId, dao: null, balances: { near: "1000000000000000000000000", usdc: "5000000" }, confidential: linked.confidential, payees: [] } : null; + }), + deposit: async (organization, agent, asset) => funding("deposit", organization, agent, [asset], () => { + const address = `${(deposits.length + 1).toString(16).padStart(64, "0")}`; + deposits.push({ agent, asset, address }); + return { correlationId: `deposit-${deposits.length}`, address, memo: null, minAmount: null, expiresAt: new Date(Date.now() + 3_600_000).toISOString() }; + }), + link: async (organization, agent, accountId) => funding("link", organization, agent, [accountId], () => { treasuries[agent] = { accountId, confidential: false }; }), + unlink: async (organization, agent) => funding("unlink", organization, agent, [], () => { delete treasuries[agent]; }), + setConfidential: async (organization, agent, confidential) => funding("setConfidential", organization, agent, [String(confidential)], () => { + if (treasuries[agent]) treasuries[agent].confidential = confidential; + }), + }, }; /** The agent's creation completes: it has an ID from now on. */ const create = (agent: string) => { unborn.splice(unborn.indexOf(agent), 1); }; @@ -297,7 +329,7 @@ export function fakePlatform(owners: Record, { failing = {}, for found.state = verdict === "approve" ? "approved" : "rejected"; return { ok: true, value: shown(found) }; } - return { platform, calls, consents, consentAnswers, proposals, proposalAnswers, daoRequests, daoAnswers, daoVotes, voteAnswers, + return { platform, calls, consents, consentAnswers, proposals, proposalAnswers, daoRequests, daoAnswers, daoVotes, voteAnswers, treasuries, deposits, fundingAnswers, reservations, setupAnswers, daoSetups, daoSetupAnswers, creations, creationAnswers, statusAnswers, create, reads, readAnswers }; } @@ -348,7 +380,7 @@ export function proposal(change: Partial = {}): ProposalView { } /** The owner reads by name, and the platform procedure each one is. */ -const READ_PATHS = { overview: "overview", activity: "activity", held: "held", unresolved: "unresolved", runs: "autopilot/evidence", recording: "autopilot/log" } as const; +const READ_PATHS = { account: "account", overview: "overview", activity: "activity", held: "held", unresolved: "unresolved", runs: "autopilot/evidence", recording: "autopilot/log" } as const; type ReadName = keyof typeof READ_PATHS; type ReadPath = (typeof READ_PATHS)[K]; type ReadValue = Extract>, { ok: true }>["value"] | (K extends "recording" ? undefined : never); diff --git a/apps/server/test/funding.test.ts b/apps/server/test/funding.test.ts new file mode 100644 index 0000000..1d02993 --- /dev/null +++ b/apps/server/test/funding.test.ts @@ -0,0 +1,155 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer as createHttpServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import type { AuditEntry } from "../src/audit.ts"; +import * as contract from "../src/contract.ts"; +import { createDaoRequests } from "../src/dao-requests.ts"; +import { standInDaos, type Policy } from "../src/dao.ts"; +import { createServer } from "../src/http.ts"; +import { standInKeyLookup } from "../src/keys.ts"; +import { createSessions } from "../src/sessions.ts"; +import { createSignIn } from "../src/signin.ts"; +import { fakePlatform, HOST, ORIGIN, wallet } from "./fakes.ts"; +import { tempDir } from "../../../scripts/temp-dirs.ts"; + +const DAO = "agentic.sputnik-dao.near"; +const owner = wallet("owner.near"), intruder = wallet("intruder.near"), council = wallet("council.near"), reader = wallet("reader.near"), admin = wallet("admin.near"); +const fake = fakePlatform({ portfolio: "owner.near", theirs: "intruder.near", daoagent: DAO }, { daos: ["daoagent"] }); +const policy: Policy = { + proposal_bond: "100000000000000000000000", proposal_period: String(7 * 86_400 * 1e9), default_vote_policy: { threshold: [1, 2] }, + roles: [{ name: "all", kind: "Everyone", permissions: [], vote_policy: {} }, + { name: "council", kind: { Group: ["council.near"] }, permissions: ["call:AddProposal", "call:VoteApprove", "call:VoteReject"], vote_policy: {} }, + { name: "readers", kind: { Group: ["reader.near"] }, permissions: [], vote_policy: {} }, + { name: "admins", kind: { Group: ["admin.near"] }, permissions: ["*:*"], vote_policy: {} }], +}; +const audit: AuditEntry[] = []; +let server: Server, base: string; +const cookies: Record = {}; + +before(async () => { + server = createHttpServer(createServer({ + platform: fake.platform, + signIn: createSignIn({ recipient: HOST, ttlMs: 5 * 60_000, perMinute: 1_000, + keyLookup: standInKeyLookup(Object.fromEntries([owner, intruder, council, reader, admin].map(who => [who.accountId, [who.publicKey]]))) }), + sessions: createSessions({ idleMs: 30 * 60_000, maxMs: 12 * 60 * 60_000 }), + publicOrigin: ORIGIN, deployment: "treasury", trustedProxyHops: 0, ownerRequestsPerMinute: 1_000, + audit: { append: async entry => { audit.push(entry); } }, consentsPerDay: 1_000, daoFilingsPerDay: 1_000, keyLookup: async () => "full", + daos: standInDaos({ [DAO]: policy }, { [DAO]: [] }), daoRequests: await createDaoRequests(join(tempDir("treasury-funding-"), "dao-requests.json")), + })); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + for (const who of [owner, intruder, council, reader, admin]) cookies[who.accountId] = await signIn(who); +}); +after(() => server.close()); + +async function signIn(who: ReturnType) { + const issued = await (await fetch(`${base}/api/session/challenge`, { method: "POST", headers: { origin: ORIGIN, "content-type": "application/json" }, body: "{}" })).json() as any; + const response = await fetch(`${base}/api/session`, { method: "POST", headers: { origin: ORIGIN, "content-type": "application/json" }, body: JSON.stringify(who.sign(issued.value)) }); + return response.headers.get("set-cookie")!.split(";")[0]!; +} +const call = async (method: "GET" | "POST", path: string, as: ReturnType = owner, body: unknown = {}) => { + const response = await fetch(`${base}${path}`, { method, headers: { cookie: cookies[as.accountId]!, ...(method === "POST" && { origin: ORIGIN, "content-type": "application/json" }) }, + ...(method === "POST" && { body: JSON.stringify(body) }) }); + return { status: response.status, text: await response.clone().text(), body: await response.json() as any }; +}; +const agent = "/api/agents/portfolio"; +const fundingCalls = () => fake.calls.filter(made => made.startsWith("funding.")); +const reset = () => { fake.calls.length = 0; audit.length = 0; }; + +test("the linked account reads null until the owner links one; linked, it reads back with its balances", async () => { + reset(); + assert.deepEqual(contract.treasuryRead.parse((await call("GET", `${agent}/treasury`)).body.value), { treasury: null }); + const linked = await call("POST", `${agent}/treasury/link`, owner, { accountId: "funds.near" }); + assert.deepEqual([linked.status, linked.body], [200, { ok: true, value: {} }]); + const read = contract.treasuryRead.parse((await call("GET", `${agent}/treasury`)).body.value); + assert.equal(read.treasury?.accountId, "funds.near"); + assert.deepEqual(fundingCalls(), ["funding.treasury owner--near portfolio", "funding.link owner--near portfolio funds.near", "funding.treasury owner--near portfolio"], + "under the owner's Organization"); + assert.deepEqual(audit.map(entry => [entry.action, entry.stage, entry.who, "answer" in entry ? entry.answer.ok : undefined]), [["link", "attempt", "owner.near", undefined], ["link", "result", "owner.near", true]]); +}); + +test("marking the account confidential and unlinking it are relayed once each, and recorded before and after", async () => { + reset(); + assert.equal((await call("POST", `${agent}/treasury/confidential`, owner, { confidential: true })).status, 200); + assert.equal(contract.treasuryRead.parse((await call("GET", `${agent}/treasury`)).body.value).treasury?.confidential, true); + assert.equal((await call("POST", `${agent}/treasury/unlink`)).status, 200); + assert.equal(contract.treasuryRead.parse((await call("GET", `${agent}/treasury`)).body.value).treasury, null); + assert.deepEqual(fundingCalls().filter(made => !made.startsWith("funding.treasury")), + ["funding.setConfidential owner--near portfolio true", "funding.unlink owner--near portfolio"]); + assert.deepEqual(audit.map(entry => [entry.action, entry.stage]), [["confidential", "attempt"], ["confidential", "result"], ["unlink", "attempt"], ["unlink", "result"]]); +}); + +test("a malformed change is refused before anything is asked or recorded", async () => { + reset(); + for (const [path, body] of [["link", { accountId: "Not An Account" }], ["link", {}], ["confidential", { confidential: "yes" }], ["unlink", { extra: 1 }]] as const) + assert.deepEqual((await call("POST", `${agent}/treasury/${path}`, owner, body)).body, { ok: false, kind: "refused", code: "bad_request" }, path); + assert.deepEqual([fundingCalls(), audit], [[], []]); +}); + +test("the platform's refusal is funding_refused without its text, no consent is not_connected, and a lost answer is unavailable: each recorded", async () => { + reset(); + fake.fundingAnswers.link = [{ ok: false, kind: "refused", status: 400, error: "account does not exist: secret detail" }, + { ok: false, kind: "forbidden", status: 403, error: "No live owner consent" }, new Error("socket hang up")]; + const answers = []; + for (let i = 0; i < 3; i++) answers.push(await call("POST", `${agent}/treasury/link`, owner, { accountId: "funds.near" })); + assert.deepEqual(answers.map(({ status, body }) => [status, body]), [ + [409, { ok: false, kind: "refused", code: "funding_refused" }], + [409, { ok: false, kind: "refused", code: "not_connected" }], + [503, { ok: false, kind: "unavailable" }], + ]); + assert.ok(!answers[0]!.text.includes("secret detail"), "the platform's text stays on the server"); + assert.deepEqual(audit.filter(entry => entry.stage === "result").map(entry => "answer" in entry ? entry.answer : undefined), [ + { ok: false, kind: "refused", status: 400, code: "funding_refused" }, + { ok: false, kind: "forbidden", status: 403, code: "not_connected" }, + { ok: false, kind: "unavailable" }, + ]); +}); + +test("a deposit address is asked for by asset, NEAR as wNEAR, each one fresh; any other asset is refused unasked", async () => { + reset(); + const usdc = await call("POST", `${agent}/deposit`, owner, { asset: "USDC" }); + assert.equal(usdc.status, 200, usdc.text); + const first = contract.depositRead.parse(usdc.body.value).deposit; + const near = contract.depositRead.parse((await call("POST", `${agent}/deposit`, owner, { asset: "NEAR" })).body.value).deposit; + assert.notEqual(first.address, near.address); + assert.deepEqual((await call("POST", `${agent}/deposit`, owner, { asset: "ETH" })).body, { ok: false, kind: "refused", code: "bad_request" }); + assert.deepEqual(fundingCalls(), [ + "funding.deposit owner--near portfolio nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + "funding.deposit owner--near portfolio nep141:wrap.near", + ]); + assert.deepEqual(audit.map(entry => [entry.action, entry.stage, entry.subject]), + [["deposit", "attempt", undefined], ["deposit", "result", first.correlationId], ["deposit", "attempt", undefined], ["deposit", "result", near.correlationId]]); +}); + +test("someone else's agent is not_owner for every funding route, and nothing is asked", async () => { + reset(); + for (const [method, path, body] of [["GET", "treasury", undefined], ["POST", "deposit", { asset: "USDC" }], ["POST", "treasury/link", { accountId: "funds.near" }], + ["POST", "treasury/unlink", {}], ["POST", "treasury/confidential", { confidential: false }]] as const) + assert.deepEqual((await call(method, `${agent}/${path}`, intruder, body)).body, { ok: false, kind: "not_owner" }, path); + assert.deepEqual(fundingCalls(), []); +}); + +test("a DAO's council reads its funding account and asks for a deposit address, but its funding account is the DAO: no link, unlink or setting", async () => { + reset(); + const dao = "/api/agents/daoagent"; + assert.equal((await call("GET", `${dao}/treasury`, council)).status, 200); + assert.equal((await call("POST", `${dao}/deposit`, council, { asset: "USDC" })).status, 200); + for (const [path, body] of [["link", { accountId: "funds.near" }], ["unlink", {}], ["confidential", { confidential: true }]] as const) { + assert.deepEqual((await call("POST", `${dao}/treasury/${path}`, council, body)).body, { ok: false, kind: "refused", code: "not_permitted" }, path); + // A member with every DAO permission passes the roles check, and is refused all the same. + assert.deepEqual((await call("POST", `${dao}/treasury/${path}`, admin, body)).body, { ok: false, kind: "refused", code: "dao_funding_fixed" }, `${path}, every permission`); + } + const organization = DAO.replaceAll(".", "--"); + assert.deepEqual(fundingCalls(), [`funding.treasury ${organization} daoagent`, + `funding.deposit ${organization} daoagent nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1`]); + assert.deepEqual(audit.map(entry => entry.action), ["deposit", "deposit"]); +}); + +test("a DAO member whose roles don't allow filing proposals may read the funding account, but not ask for a deposit address", async () => { + reset(); + assert.equal((await call("GET", "/api/agents/daoagent/treasury", reader)).status, 200); + assert.deepEqual((await call("POST", "/api/agents/daoagent/deposit", reader, { asset: "USDC" })).body, { ok: false, kind: "refused", code: "not_permitted" }); + assert.deepEqual(fundingCalls(), [`funding.treasury ${DAO.replaceAll(".", "--")} daoagent`]); +}); diff --git a/apps/server/test/http.test.ts b/apps/server/test/http.test.ts index 5535c44..3dd341b 100644 --- a/apps/server/test/http.test.ts +++ b/apps/server/test/http.test.ts @@ -830,7 +830,7 @@ test("a DAO policy that can't be read admits no one: unavailable, and logged", a assert.ok(logged.mock.calls.some(c => String(c.arguments[0]).includes("dao_policy_unavailable"))); }); -test("each agent route asks for what it needs: a member who may only file is refused execute, one with no rights is refused filing, and no member signs", async t => { +test("each agent route asks for what it needs: a member who may only file is refused execute, one with no rights is refused filing, and no member signs or links", async t => { policies[DAO] = DAO_POLICY; t.after(() => { delete policies[DAO]; }); const needs = Object.fromEntries(routes.flatMap(route => route.access === "owner" || route.access === "owner-fresh" ? [[`${route.method} ${route.path}`, route.needs]] : [])); @@ -841,6 +841,9 @@ test("each agent route asks for what it needs: a member who may only file is ref file: { council: true, proposer: true, reader: false }, execute: { council: true, proposer: false, reader: false }, sign: { council: false, proposer: false, reader: false }, + // A deposit address, as filing: anyone who may file a proposal. The funding account is the DAO's own, so no member links it. + deposit: { council: true, proposer: true, reader: false }, + link: { council: false, proposer: false, reader: false }, }; for (const route of routes.filter(route => route.access === "owner" || route.access === "owner-fresh")) { const need = needs[`${route.method} ${route.path}`]!; diff --git a/apps/server/test/reads.test.ts b/apps/server/test/reads.test.ts index d871f82..80f25a8 100644 --- a/apps/server/test/reads.test.ts +++ b/apps/server/test/reads.test.ts @@ -49,6 +49,7 @@ const READS = [ ["held", `${agent}/held`, contract.heldRead], ["unresolved", `${agent}/unresolved`, contract.unresolvedRead], ["runs", `${agent}/runs`, contract.runsRead], + ["account", `${agent}/account`, contract.accountRead], ] as const; test("the overview reaches the page under the owner's Organization, every amount an exact decimal string, a balance past 2^53 included", async () => { @@ -64,7 +65,7 @@ test("the overview reaches the page under the owner's Organization, every amount assert.deepEqual(fake.calls.filter(made => made.startsWith("reads.")), ["reads.overview owner--near portfolio"]); }); -test("activity, held, unresolved and the autopilot's runs reach the page as the platform answers them, a bigint in a request included", async () => { +test("activity, held, unresolved, the autopilot's runs and the account reach the page as the platform answers them, a bigint in a request included", async () => { const request = { amount: BIG, to: "payee.near" }; fake.reads.activity = [{ at: "2026-11-05T00:00:00.000Z", kind: "swap", request, correlationId: "c1", status: "SUCCESS" }]; fake.reads.held = [{ at: "2026-11-05T00:00:00.000Z", kind: "pay", request, correlationId: "c2", approvalId: "a2" }]; @@ -74,7 +75,7 @@ test("activity, held, unresolved and the autopilot's runs reach the page as the const { status, body, text } = await get(path); assert.equal(status, 200, `${name}: ${text}`); schema.parse(body.value); - if (name !== "runs") assert.equal(body.value[0].request.amount, String(BIG), name); + if (name !== "runs" && name !== "account") assert.equal(body.value[0].request.amount, String(BIG), name); } assert.deepEqual((await get(`${agent}/runs`)).body.value, { ticks: [], corrupt: [3] }); }); diff --git a/apps/server/test/stack.e2e.ts b/apps/server/test/stack.e2e.ts index 74a18f0..4ad550d 100644 --- a/apps/server/test/stack.e2e.ts +++ b/apps/server/test/stack.e2e.ts @@ -251,3 +251,32 @@ test("the owner's reads reach the stack's portfolio under their consent: overvie const runs = await read("runs", contract.runsRead); assert.ok(Array.isArray(runs.ticks)); }); + +test("the owner links their own account to fund the agent, reads it back, gets a USDC deposit address, and unlinks it", async () => { + const issued = await challenge(); + const signedIn = cookieOf(await post("/api/session", { ...(await signOwner(target, issued)), nonce: issued.nonce })); + const live = ((await (await get(`/api/agents/${target.agent}/consent`, signedIn)).json()) as { value: { consent: unknown } }).value.consent; + const { cookie, agent } = live ? { cookie: signedIn, agent: `/api/agents/${target.agent}` } : await connect(); + const treasury = async () => { + const response = await get(`${agent}/treasury`, cookie); + const body = await response.json() as { value: unknown }; + assert.equal(response.status, 200, JSON.stringify(body)); + return contract.treasuryRead.parse(body.value).treasury; + }; + if (await treasury()) assert.equal((await post(`${agent}/treasury/unlink`, {}, cookie)).status, 200); + assert.equal(await treasury(), null); + const linked = await post(`${agent}/treasury/link`, { accountId: owner }, cookie); + assert.equal(linked.status, 200, await linked.clone().text()); + assert.equal((await treasury())?.accountId, owner); + // The stack's simulated provider takes USDC deposits only, and answers the agent's own wallet as the address + // (platform 35683b2, test/e2e/provider.mjs): NEAR and a fresh address each time are the unit tests'. + const deposit = await post(`${agent}/deposit`, { asset: "USDC" }, cookie); + const body = await deposit.json() as { value: unknown }; + assert.equal(deposit.status, 200, JSON.stringify(body)); + contract.depositRead.parse(body.value); + assert.equal((await post(`${agent}/treasury/unlink`, {}, cookie)).status, 200); + assert.equal(await treasury(), null); + const account = await get(`${agent}/account`, cookie); + assert.equal(account.status, 200); + contract.accountRead.parse(((await account.json()) as { value: unknown }).value); +}); diff --git a/web/PLATFORM-SESSION.md b/web/PLATFORM-SESSION.md index dc70dfa..e62e155 100644 --- a/web/PLATFORM-SESSION.md +++ b/web/PLATFORM-SESSION.md @@ -224,6 +224,14 @@ A second build (`src/proposals/`, built to `dist-proposals/`), served only by th | Portfolio, Check again | region and heading (level 2), button | `/agents/`, once connected | | Holdings | table (Asset, Amount exactly, Value) | Portfolio | | Needs attention, Activity, Autopilot runs | headings (level 3); lists | Portfolio | +| Funding, Check again | region and heading (level 2), button | `/agents/`, once connected | +| Funding account; Money in: from `` to this agent; Receive funds | headings (level 3) | Funding | +| Funded from ``. or Funded from ``, a DAO with `` members. | status | Funding account, once linked | +| Use ``, Funding NEAR account, Link; Unlink, Unlink the funding account?; Confidential in Trezu | buttons, field; button, dialog; checkbox (a linked DAO) | Funding account, a wallet owner only | +| Funding amount, Funding asset, Review transfer or Review proposal | field, select, button | Money in | +| Funding transfer to sign; Cancel, Send or File funding proposal | group (the exact transfer); buttons | Money in, once reviewed | +| Open payment in Trezu | link, with the agent's `nearcom:` recipient | Money in, a confidential DAO | +| Get a deposit address or Get a new deposit address | button | Receive funds | | Signed in as ``, a member of ``, which owns this agent | text | signed in, a DAO-owned agent | | Ask the DAO to connect it; Connected by the vote of ``: … or Waiting for the vote of `` to connect this agent. | button; status | Connection, a DAO-owned agent | | stop this agent | link (the platform's DAO stop page), in place of Disconnect | Connection, a DAO-owned agent | @@ -236,6 +244,7 @@ How it departs from the brief, or decides what the brief leaves open: - **The agent comes from the address** (`/agents/`). `/` lists the signed-in account's own treasuries (`GET /api/setup`, the agents its consents name under its Organization), and still opens an agent by name, for one a DAO owns. - **Setup (#12) is the app server's**, through `/apps/v1/setup` and `/apps/v1/dao-setups`, not the owner session's `/gw/agents`. An account with no Treasury signs the server's setup text; the agent's page then offers its creation (`agent_create` under the setup consent), signed on the platform's confirm page, or for a DAO filed for its vote. The setup consent lasts an hour; after that, creating asks for the setup again. The stack sets up Payables agents only (`hostedAreas: ['payables']` at `35683b2`), so setup and creation are covered by the server's unit tests, not a stack test. - **The portfolio is read-only (#7 on `/apps/v1`).** It shows the owner reads (#61) once the agent is connected: holdings with exact amounts, what needs attention, the latest 20 activity rows and 10 autopilot runs. Rebalancing, plans, the steward and the inbox aren't here: they need `treasury-execute` (which this app never asks for) or procedures no app scope covers yet (platform#17). Each read is a call from the deployment's shared 1,200 an hour, so the page reads them once and again only on "Check again". +- **Funding is the app's, under its `treasury` consent.** The funding account is read with `treasury/get`; a wallet owner links, unlinks and marks it confidential through the server, and a DAO agent's account is the DAO, fixed at setup, so no member gets those controls, whatever their roles. Money in asks the server for a fresh `deposit` address, builds the transfer with the SDK's `fundingTransaction` in place of the owner session's `treasury/fund`, checks and shows it exactly, then reads the account again before the funding account signs: the owner's wallet sends it, or Trezu files it as the DAO's proposal. After an answer the wallet doesn't give, it isn't offered again while the page is loaded. A confidential DAO pays from Trezu to the agent's `nearcom:` wallet, read with `account`. The stack takes USDC deposits only and answers the agent's wallet as the address, so NEAR funding and fresh addresses are the unit tests'; the stack's wallets record what they're asked to send and send nothing. - **Signed in or not** is read from `GET /api/agents/:agent` on an agent's pages, and from `GET /api/setup` or `GET /api/setup/dao/:dao` on the others: the server has no session route. `not_signed_in` there shows Sign in; another read answering `not_signed_in` later reads the identity again, so the page returns to Sign in. - **The wallet's account is checked before relaying.** A consent or verdict signed by another account than the agent's owner is not sent: "Your wallet signed as ``, but this agent's owner is ``. Nothing was sent." The server would refuse it anyway (`not_owner`, `signature_invalid`). - **A verdict whose answer was lost** (or `unavailable`) reads the proposal: if it now reads approved (rejected), it says "You approved this proposal." (rejected), as the server's own recovery decides it; otherwise the read is shown and nothing is signed again without a new review. diff --git a/web/src/features/funding/funding-transaction.ts b/web/src/features/funding/funding-transaction.ts index a654978..2f3129b 100644 --- a/web/src/features/funding/funding-transaction.ts +++ b/web/src/features/funding/funding-transaction.ts @@ -37,7 +37,8 @@ export function expiryProblem(expiresAt: string | null, now: number) { } /** - * Reads the transaction `treasury/fund` prepared, and answers the transfer it makes, or why it must not be signed: it + * Reads a funding transaction (the one `treasury/fund` prepared, or the SDK's `fundingTransaction` for a `deposit` + * address), and answers the transfer it makes, or why it must not be signed: it * must pay exactly `amount` of `asset` into the deposit address it came with (wNEAR wrapped from the same NEAR amount), * with nothing else in it but one storage registration for that address. A deposit address that needs a memo, a * minimum above the amount, or that expires within the margin can't be paid by this transaction. A DAO pays only once diff --git a/web/src/proposals/app.tsx b/web/src/proposals/app.tsx index e31c156..7de32a9 100644 --- a/web/src/proposals/app.tsx +++ b/web/src/proposals/app.tsx @@ -5,9 +5,11 @@ import { accountId, agentName } from "../../../apps/server/src/contract.ts"; import { TreasuryBrand } from "../app/brand"; import { Loading, QueryError } from "../shared/ui"; import { Connection } from "./connection"; +import { Funding } from "./funding"; import { ProposalDetail } from "./proposal-detail"; import { Portfolio } from "./portfolio"; import { ProposalList } from "./proposal-list"; +import type { Identity } from "./proposals-api"; import { agentReads, failureOf } from "./queries"; import { SignedIn, SignIn } from "./session"; import { CreateAgent, DaoSetup, Home } from "./setup"; @@ -64,7 +66,7 @@ function Agent({ name, id }: { name: string; id: string | undefined }) { {heading} {signedIn} - {id === undefined && } + {id === undefined && } {id === undefined ? : proposalId.safeParse(id).success ? :

@@ -74,8 +76,8 @@ function Agent({ name, id }: { name: string; id: string | undefined }) { ; } -/** The portfolio, once a live consent connects the agent: every owner read needs it. */ -function ConnectedPortfolio({ reads }: { reads: ReturnType }) { +/** The portfolio and funding, once a live consent connects the agent: every owner read and funding call needs it. */ +function Connected({ reads, identity }: { reads: ReturnType; identity: Identity }) { const consent = useQuery(reads.consent); - return consent.data?.consent ? : null; + return consent.data?.consent ? <> : null; } diff --git a/web/src/proposals/funding.tsx b/web/src/proposals/funding.tsx new file mode 100644 index 0000000..7c569e3 --- /dev/null +++ b/web/src/proposals/funding.tsx @@ -0,0 +1,280 @@ +import { fundingTransaction, mayCall } from "@multiagency/sdk"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { blockFunding, useFundingBlock } from "../features/funding/funding-block"; +import { decimalsOf, expiryProblem, fundingTransfer, type FundingAsset, type Transfer } from "../features/funding/funding-transaction"; +import { Field, focusInvalid } from "../shared/form"; +import { accountError } from "../shared/form-validation"; +import { ago, fromUnits, toUnits } from "../shared/format"; +import { Button, Card, CopyButton, ErrorBox, errorMessage, input, Loading, QueryError, Spinner } from "../shared/ui"; +import type { DepositRead, Identity, TreasuryRead } from "./proposals-api"; +import type { agentReads } from "./queries"; +import { ReviewDialog } from "./ui"; +import { fund, WrongAccount } from "./wallet"; +import { failureText } from "./words"; + +type Reads = ReturnType; +type Linked = NonNullable; + +/** + * Money into the agent, under the app's consent (its `treasury` scope): the account linked to fund it, a transfer from + * that account to a fresh deposit address, and a deposit address anyone may pay. A wallet owner links the account; + * a DAO agent's account is the DAO itself, fixed at setup. Shown once the agent is connected. + */ +export function Funding({ reads, identity }: { reads: Reads; identity: Identity }) { + const client = useQueryClient(); + const treasury = useQuery(reads.treasury); + const refresh = () => void client.invalidateQueries({ queryKey: reads.treasury.queryKey }); + const linked = treasury.data ?? null; + + return
+
+

Funding

+ +
+ +

Funding account

+ {treasury.isPending ? + : treasury.isError ? treasury.refetch()} /> + : } +
+ {linked && +

Money in: from {linked.accountId} to this agent

+ {linked.dao && linked.confidential ? : } +
} + +

Receive funds

+ +
+
; +} + +/** The linked account, and for a wallet owner the controls that change it: link, unlink, and a DAO's confidential setting. */ +function FundingAccount({ reads, identity, linked }: { reads: Reads; identity: Identity; linked: TreasuryRead }) { + const client = useQueryClient(); + // A DAO agent's funding account is the DAO, fixed at setup: no member changes it, whatever their roles. + const canChange = identity.signer !== "dao" && mayCall(identity.access, "treasury/link"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const [unlinking, setUnlinking] = useState(false); + /** One change, sent once; the account is read again after it, whatever the answer. */ + const change = async (send: () => ReturnType) => { + setBusy(true); + setError(undefined); + const answer = await send(); + setBusy(false); + setUnlinking(false); + if (!answer.ok) setError(failureText(answer, "funding")); + void client.invalidateQueries({ queryKey: reads.treasury.queryKey }); + }; + + if (!linked) return canChange + ? change(() => reads.agent.link(accountId))} /> + :

No funding account is linked.

; + return
+

Funded from {linked.accountId} + {linked.dao ? `, a DAO with ${linked.dao.members.length} member${linked.dao.members.length === 1 ? "" : "s"}` : ""}.

+
+
NEAR
{fromUnits(linked.balances.near, 24)}
+
USDC
{fromUnits(linked.balances.usdc, 6)}
+
+ {identity.signer === "dao" &&

A DAO agent is funded by its DAO, fixed at setup.

} + {canChange && linked.dao && } + {canChange && } + + setUnlinking(false)} title="Unlink the funding account?" + description={`${linked.accountId} no longer funds this agent from here. Money already in the agent stays.`} + actions={}>{null} +
; +} + +function LinkAccount({ account, busy, error, onLink }: { account: string; busy: boolean; error: string | undefined; onLink: (accountId: string) => void }) { + const [accountId, setAccountId] = useState(""); + const [submitted, setSubmitted] = useState(false); + const problem = accountError(accountId); + return
+

No funding account is linked. Link the NEAR account that funds this agent: your own, or a DAO's.

+ +
{ + event.preventDefault(); + setSubmitted(true); + if (problem) { focusInvalid(event.currentTarget); return; } + if (!busy) onLink(accountId.trim()); + }}> + setAccountId(event.target.value)} error={submitted ? problem : undefined} /> + + + +
; +} + +type Review = { deposit: DepositRead; transaction: ReturnType; transfer: Transfer; account: string }; + +/** + * A transfer from the funding account to a fresh deposit address, in two steps: the server asks the platform for the + * address and the SDK builds the transfer (`fundingTransaction`), which this page checks and shows exactly; only then + * does the funding account sign it, once the account has been read again and is still the one reviewed. A wallet sends + * it; for a DAO, Trezu files it as one proposal. After an answer the wallet doesn't give, it's not offered again while + * the page is loaded. + */ +function Fund({ reads, identity, linked }: { reads: Reads; identity: Identity; linked: Linked }) { + const client = useQueryClient(); + const dao = Boolean(linked.dao); + const blocked = useFundingBlock(reads.name); + const canFund = mayCall(identity.access, "deposit") && !blocked; + const [asset, setAsset] = useState("USDC"); + const [value, setValue] = useState(""); + const [submitted, setSubmitted] = useState(false); + const [review, setReview] = useState(); + const [busy, setBusy] = useState(); + const [error, setError] = useState(); + const [done, setDone] = useState(); + const decimals = decimalsOf(asset); + const amount = toUnits(value, decimals); + + const prepare = async (amount: string) => { + setBusy("Preparing the transfer…"); + setError(undefined); + setDone(undefined); + const asked = await reads.agent.deposit(asset); + setBusy(undefined); + if (!asked.ok) { setError(failureText(asked, "funding")); return; } + const transaction = fundingTransaction(asset, asked.value.deposit.address, amount); + const read = fundingTransfer({ deposit: asked.value.deposit, transaction }, asset, amount, { dao }); + if ("problem" in read) { setError(read.problem); return; } + setReview({ deposit: asked.value.deposit, transaction, transfer: read.transfer, account: linked.accountId }); + }; + + const sign = async (reviewed: Review) => { + setReview(undefined); + setError(undefined); + setBusy(dao ? "File the proposal in Trezu…" : "Complete the transfer in your wallet…"); + const mayHaveSent = dao + ? `The proposal may have been filed: check ${reviewed.account}'s requests in Trezu before filing again.` + : `The transfer may have been sent: check ${reviewed.account} and this agent's balances before sending again.`; + try { + const now = await client.fetchQuery({ ...reads.treasury, staleTime: 0 }); + if (now?.accountId !== reviewed.account) throw new Error(`The funding account is no longer ${reviewed.account}. Nothing was sent; review the transfer again.`); + // The address may have come close to expiring while the review was open. + const expired = expiryProblem(reviewed.deposit.expiresAt, Date.now()); + if (expired) throw new Error(expired); + let outcome: Awaited>; + try { + outcome = await fund(reviewed.transaction, reviewed.account, { dao }); + } catch (problem) { + if (problem instanceof WrongAccount) throw problem; + blockFunding(reads.name, mayHaveSent); + throw new Error(`${errorMessage(problem)} ${mayHaveSent}`); + } + // One receipt, reverted as one: nothing moved, or nothing was filed. + if (outcome.kind === "failed") throw new Error(`The transaction failed, so nothing was ${dao ? "filed" : "sent"}.`); + if (outcome.kind === "unknown") { + blockFunding(reads.name, mayHaveSent); + throw new Error(`The wallet didn't report how the transaction ended. ${mayHaveSent}`); + } + setValue(""); + setSubmitted(false); + setDone(dao ? `Filed in Trezu. It pays out once the DAO approves: review and vote at trezu.app/${reviewed.account}/requests.` + : "Sent. The portfolio updates once the deposit lands."); + } catch (problem) { + setError(errorMessage(problem)); + } finally { + setBusy(undefined); + } + }; + + return
+

{dao ? "Files one proposal in Trezu: the DAO pays a fresh deposit address once its members approve." + : "Sends from the funding account to a fresh deposit address."} Funds land in the agent's private balance.

+ {review ?
+

{dao ? "The proposal pays" : "Your wallet signs a transfer of"} exactly {review.transfer.amount} {review.transfer.symbol} + {review.transfer.symbol === "NEAR" && " (wrapped as wNEAR)"} from {review.account} to the deposit address:

+ {review.transfer.address} +

Through the {review.transfer.contract} contract, with {review.transfer.storage} NEAR attached to + register the address.{review.transfer.expiresAt && ` The address expires ${ago(review.transfer.expiresAt)}.`}

+ {review.transfer.caveat &&

{review.transfer.caveat}

} +
+ + +
+
+ :
{ + event.preventDefault(); + setSubmitted(true); + if (!amount) { focusInvalid(event.currentTarget); return; } + if (busy === undefined && canFund) void prepare(amount); + }}> + setValue(event.target.value)} error={submitted && !amount ? `Enter an amount greater than zero, with up to ${decimals} decimal places.` : undefined} /> + + + } +

{busy ?? done}

+ {blocked && !error &&

{blocked}

} + +
; +} + +/** + * A confidential DAO pays from its private balance, in Trezu. Only a `nearcom:` recipient keeps the payment private: a + * bare account sends it out as a public withdrawal to the agent's wallet. + */ +function ConfidentialFund({ reads, linked }: { reads: Reads; linked: Linked }) { + const account = useQuery(reads.account); + if (account.isPending) return ; + if (account.isError) return account.refetch()} />; + if (!account.data.wallet) return

The agent has no wallet yet, so a confidential payment has nowhere to land.

; + const recipient = `nearcom:${account.data.wallet}`; + return
+

Create a payment in Trezu from the confidential balance, to the near.com network. It never touches the public + chain and lands in the agent's private balance once the DAO approves.

+
+ {recipient} + +
+

Keep the nearcom: prefix. Without it the payment leaves the private side and lands publicly.

+ Open payment in Trezu +
; +} + +/** A one-time deposit address for USDC on NEAR, which anyone may pay with no signature from here. */ +function Deposit({ reads, identity }: { reads: Reads; identity: Identity }) { + const [deposit, setDeposit] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const canDeposit = mayCall(identity.access, "deposit"); + const request = async () => { + setBusy(true); + setError(undefined); + const asked = await reads.agent.deposit("USDC"); + setBusy(false); + if (asked.ok) setDeposit(asked.value.deposit); + else setError(failureText(asked, "funding")); + }; + return
+

Send USDC on NEAR to a one-time deposit address; it lands in the agent's private balance.

+ {deposit &&
+ Deposit address +
+ {deposit.address} + +
+ {deposit.memo &&

Memo {deposit.memo} (required)

} +

{deposit.minAmount && `At least ${fromUnits(deposit.minAmount, 6)} USDC. `} + {deposit.expiresAt && `Expires ${ago(deposit.expiresAt)}. `}Do not send to an expired or already used address.

+
} + + {!canDeposit &&

A member who may file proposals asks for one.

} + +
; +} diff --git a/web/src/proposals/proposals-api.ts b/web/src/proposals/proposals-api.ts index cf887eb..0aba446 100644 --- a/web/src/proposals/proposals-api.ts +++ b/web/src/proposals/proposals-api.ts @@ -103,6 +103,17 @@ export function proposalsApi(fetcher: typeof fetch = (input, init) => fetch(inpu unresolved: () => call(contract.unresolvedRead, `${base}/unresolved`), runs: () => call(contract.runsRead, `${base}/runs`), recording: (tick: string) => call(contract.recordingRead, `${base}/runs/${readId.parse(tick)}`), + /** The agent's account: its wallet, which holds its balances, and the grants its executor holds. */ + account: () => call(contract.accountRead, `${base}/account`), + /** The account linked to fund the agent, or null. */ + treasury: () => call(contract.treasuryRead, `${base}/treasury`), + /** A fresh one-time deposit address for USDC, or for NEAR (as wNEAR). A lost answer may be asked for again: each is new. */ + deposit: (asset: "USDC" | "NEAR") => call(contract.depositRead, `${base}/deposit`, { method: "POST", body: contract.depositRequest.parse({ asset }) }), + /** A wallet owner's changes to the funding account, each sent once; the account is read again after. */ + link: (accountId: string) => call(contract.fundingChanged, `${base}/treasury/link`, { method: "POST", body: contract.linkRequest.parse({ accountId }) }), + unlink: () => call(contract.fundingChanged, `${base}/treasury/unlink`, { method: "POST" }), + setConfidential: (confidential: boolean) => + call(contract.fundingChanged, `${base}/treasury/confidential`, { method: "POST", body: contract.confidentialRequest.parse({ confidential }) }), /** A proposal by the `requestId` it was proposed with; read only. */ request: (requestId: string) => call(contract.requestRead, `${base}/requests/${readId.parse(requestId)}`), /** An operation's status by its `correlationId`, from its activity row: null until an outcome is recorded. */ @@ -128,6 +139,8 @@ export type ProposalRead = z.output; export type OverviewRead = z.output; export type ActivityRead = z.output; export type RunsRead = z.output; +export type TreasuryRead = z.output["treasury"]; +export type DepositRead = z.output["deposit"]; /** A fresh 32-byte NEP-413 nonce, in the canonical base64 the server accepts. */ export const freshNonce = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))); diff --git a/web/src/proposals/queries.ts b/web/src/proposals/queries.ts index 8e72a68..5fbb36c 100644 --- a/web/src/proposals/queries.ts +++ b/web/src/proposals/queries.ts @@ -41,6 +41,9 @@ export function agentReads(name: string) { held: queryOptions({ queryKey: [...key, "held"], queryFn: async () => answered(await agent.held()), retry: false }), unresolved: queryOptions({ queryKey: [...key, "unresolved"], queryFn: async () => answered(await agent.unresolved()), retry: false }), runs: queryOptions({ queryKey: [...key, "runs"], queryFn: async () => answered(await agent.runs()), retry: false }), + /** The funding account and the agent's own account, read as the owner reads above. */ + treasury: queryOptions({ queryKey: [...key, "treasury"], queryFn: async () => answered(await agent.treasury()).treasury, retry: false }), + account: queryOptions({ queryKey: [...key, "account"], queryFn: async () => answered(await agent.account()), retry: false }), proposals: queryOptions({ queryKey: [...key, "proposals"], queryFn: async () => answered(await agent.proposals()).proposals, retry: false, refetchInterval: 30_000 }), /** One proposal, and for a DAO the DAO's vote on it while one waits: read again while either may change on its own. */ diff --git a/web/src/proposals/wallet.ts b/web/src/proposals/wallet.ts index e242217..ade6387 100644 --- a/web/src/proposals/wallet.ts +++ b/web/src/proposals/wallet.ts @@ -1,4 +1,5 @@ import { NearConnector } from "@fastnear/near-connect"; +import type { fundingTransaction } from "@multiagency/sdk"; import { fromBase64, HOT_WALLETS, namespaced, walletSession } from "../app/wallet"; import { transactionOutcome, type TransactionOutcome } from "../app/wallet-transaction"; import { signerProblem } from "./dao-filing"; @@ -37,3 +38,24 @@ export async function file(transaction: MemberTransaction, member: string): Prom /** The wallet is connected as another account than the member signed in: nothing was sent. */ export class WrongAccount extends Error {} + +/** + * A DAO acting through Trezu, beside the owner's own wallet: what it's sent, it files as one proposal the DAO's members + * vote on. Its connection is kept apart from the owner UI's (`proposals-dao:` storage). + */ +const trezu = walletSession(() => new NearConnector({ network: "mainnet", manifest: HOT_WALLETS, storage: namespaced("proposals-dao") }), "trezu-wallet"); + +/** + * Sends a funding transaction as `account`, the agent's funding account: from the owner's wallet, or for a DAO through + * Trezu, which files it as a proposal. Each connects first, and sends only when connected as `account` (else it throws, + * having sent nothing). Only a success the wallet reports proves it executed; a failure reverted it whole. + */ +export async function fund(transaction: ReturnType, account: string, { dao }: { dao: boolean }): Promise { + const session = dao ? trezu : wallet; + const connected = async () => (await session.connector.getConnectedWallet().catch(() => null))?.accounts[0]?.accountId; + if (await connected() !== account) await session.connect(); + const now = await connected(); + if (now !== account) throw new WrongAccount(`${dao ? "Trezu" : "Your wallet"} is connected as ${now ?? "no account"}, not the funding account ${account}. Nothing was ${dao ? "filed" : "sent"}. ${dao ? "Connect Trezu to" : "Switch the wallet to"} ${account}.`); + // The SDK types each call's arguments as `object`; they're its own plain JSON arguments, as the wallet sends them. + return transactionOutcome(await session.send(transaction as Parameters[0])); +} diff --git a/web/src/proposals/words.ts b/web/src/proposals/words.ts index 19866ab..f3f3005 100644 --- a/web/src/proposals/words.ts +++ b/web/src/proposals/words.ts @@ -3,7 +3,7 @@ import type { Outcome } from "../shared/execution-outcome.ts"; import type { Failure } from "./proposals-api.ts"; /** Where a failure happened: the same code reads differently for a sign-in and a proposal (`expired`). */ -export type Context = "read" | "sign-in" | "setup" | "consent" | "proposal"; +export type Context = "read" | "sign-in" | "setup" | "consent" | "proposal" | "funding"; const SIGN_IN: Record = { rate_limited: "Too many sign-in attempts from this address. Wait a minute, then sign in again.", @@ -47,6 +47,11 @@ const PROPOSAL: Record = { rate_limited: "Too many requests from this account. Wait a minute, then try again.", }; +const FUNDING: Record = { + funding_refused: "The platform didn't accept that change to the funding account. Nothing changed. Check the account exists, then try again.", + not_permitted: "Your roles don't allow this. A DAO agent's funding account is the DAO itself, and a deposit address is asked for by a member who may file proposals.", +}; + /** A failure in words for the owner, from its kind and code only. */ export function failureText(failure: Failure, context: Context): string { switch (failure.kind) { @@ -59,7 +64,7 @@ export function failureText(failure: Failure, context: Context): string { ? "The answer was lost or couldn't be read. Reading again is safe." : "The answer was lost, so the outcome isn't known. Read the current state before trying again."; case "refused": { - const table = context === "sign-in" ? SIGN_IN : context === "setup" ? SETUP : context === "consent" ? CONSENT : context === "proposal" ? PROPOSAL : {}; + const table = context === "sign-in" ? SIGN_IN : context === "setup" ? SETUP : context === "consent" ? CONSENT : context === "proposal" ? PROPOSAL : context === "funding" ? FUNDING : {}; return table[failure.code] ?? PROPOSAL[failure.code] ?? `The server refused the request (${failure.code}).`; } } diff --git a/web/test/dao-proposals.e2e.ts b/web/test/dao-proposals.e2e.ts index 2878a5c..635b8d5 100644 --- a/web/test/dao-proposals.e2e.ts +++ b/web/test/dao-proposals.e2e.ts @@ -33,6 +33,8 @@ let server: Server, origin: string, browser: Browser, page: Page; const unexpected: string[] = []; /** What the member's wallet was asked to send, in order. */ const sent: { signerId: string; receiverId: string; actions: unknown[] }[] = []; +/** What Trezu was asked to file as the DAO's proposals, in order: it files nothing on the stack. */ +const filed: { signerId: string; receiverId: string; actions: { params: { methodName: string; args: Record } }[] }[] = []; /** The NEP-413 hash a member's filing asks the DAO to sign: the argument of its proposal's `v1.signer` call. */ function hashOf(transaction: { actions: unknown[] }): string { @@ -85,9 +87,17 @@ before(async () => { await daoVote(target, hashOf(transaction)); return { status: { SuccessValue: "" } }; }); - await context.addInitScript(({ account, publicKey }) => { + // Trezu, connected as the DAO: what it's sent, it would file as the DAO's proposal. Recorded, never filed. + await context.exposeFunction("__trezuSend", (transaction: (typeof filed)[number]) => { filed.push(transaction); return { status: { SuccessValue: "" } }; }); + await context.addInitScript(({ account, publicKey, dao }) => { const accounts = [{ accountId: account, publicKey }]; - const call = (window as unknown as Record<"__memberSign" | "__memberSend", (p: unknown) => Promise>); + const call = (window as unknown as Record<"__memberSign" | "__memberSend" | "__trezuSend", (p: unknown) => Promise>); + const icon = "data:image/svg+xml,"; + const daoAccounts = [{ accountId: dao, publicKey }]; + const trezu = { manifest: { id: "trezu-wallet", name: "Trezu", type: "injected", icon, features: {} }, + signIn: async () => daoAccounts, getAccounts: async () => daoAccounts, signOut: async () => {}, + signAndSendTransaction: (transaction: unknown) => call.__trezuSend(transaction) }; + window.addEventListener("near-selector-ready", () => window.dispatchEvent(new CustomEvent("near-wallet-injected", { detail: trezu }))); const memberWallet = { manifest: { id: "local-test-wallet", name: "Local test wallet", type: "injected", icon: "data:image/svg+xml,", features: { signMessage: true } }, signIn: async () => accounts, getAccounts: async () => accounts, signOut: async () => {}, signMessage: (payload: { message: string; recipient: string; nonce: Uint8Array }) => @@ -95,7 +105,7 @@ before(async () => { signAndSendTransaction: (transaction: unknown) => call.__memberSend(transaction) }; window.addEventListener("near-selector-ready", () => window.dispatchEvent(new CustomEvent("near-wallet-injected", { detail: memberWallet }))); localStorage.setItem("proposals:selected-wallet:mainnet", "local-test-wallet"); - }, { account: council.accountId, publicKey: council.publicKey }); + }, { account: council.accountId, publicKey: council.publicKey, dao: state.dao.account }); page = await context.newPage(); }, { timeout: 180_000 }); @@ -152,3 +162,27 @@ test("a council member asks the DAO to approve a proposal, files the vote from t assert.deepEqual(await axe(), [], "approved"); assert.deepEqual(unexpected, [], "the page reached nothing but this server and the wallet's boundary"); }); + +test("the DAO funds its agent from its own account: no link controls, and Trezu files exactly the reviewed USDC transfer as the DAO's proposal", { timeout: 90_000 }, async () => { + await page.goto(`${origin}/agents/${target.agent}`); + const funding = page.getByRole("region", { name: "Funding" }); + await visible(funding); + await visible(funding.getByRole("status").filter({ hasText: `Funded from ${dao}, a DAO with ` })); + assert.equal(await funding.getByRole("button", { name: "Unlink" }).count(), 0, "a DAO agent's funding account is fixed"); + assert.deepEqual(await axe(), [], "the DAO's funding account"); + + await funding.getByLabel("Funding amount").fill("2"); + await funding.getByRole("button", { name: "Review proposal" }).click(); + const review = funding.getByRole("group", { name: "Funding transfer to sign" }); + await visible(review); + assert.match(await review.innerText(), new RegExp(`The proposal pays exactly 2 USDC from ${dao.replaceAll(".", "\\.")}`)); + const address = (await review.locator("code").first().innerText()).trim(); + await review.getByRole("button", { name: "File funding proposal" }).click(); + await visible(funding.getByRole("status").filter({ hasText: /^Filed in Trezu\./ })); + assert.equal(filed.length, 1, "filed once"); + assert.equal(filed[0]!.signerId, dao, "as the DAO, through Trezu"); + const ft = filed[0]!.actions.find(action => action.params.methodName === "ft_transfer")!; + assert.deepEqual([ft.params.args.receiver_id, ft.params.args.amount], [address, "2000000"]); + assert.deepEqual(await axe(), [], "filed"); + assert.deepEqual(unexpected, []); +}); diff --git a/web/test/proposals-api.test.ts b/web/test/proposals-api.test.ts index 259a564..548efd9 100644 --- a/web/test/proposals-api.test.ts +++ b/web/test/proposals-api.test.ts @@ -171,3 +171,29 @@ test("the owner reads: each a GET of the agent's route, parsed by the server's c assert.throws(() => agent.request("../consent")); assert.throws(() => agent.recording("../consent")); }); + +test("funding: the account and the funding account as GETs, a deposit address and each change to the funding account as one JSON POST", async () => { + const server = scripted(), agent = proposalsApi(server.fetcher).agent("portfolio"); + const linked = { accountId: "funds.near", dao: null, balances: { near: "1", usdc: "2" }, confidential: false, payees: [] }; + const deposit = { correlationId: "d1", address: "a".repeat(64), memo: null, minAmount: null, expiresAt: null }; + server.answer(`GET ${base}/account`, ok({ agentId: "id-portfolio", executor: true, wallet: "portfolio.near", grant: null, stagedGrant: null })); + server.answer(`GET ${base}/treasury`, ok({ treasury: linked })); + server.answer(`POST ${base}/deposit`, ok({ deposit })); + for (const path of ["link", "unlink", "confidential"]) server.answer(`POST ${base}/treasury/${path}`, ok({})); + + const account = await agent.account(); + assert.equal(account.ok && account.value.wallet, "portfolio.near"); + assert.deepEqual(await agent.treasury(), { ok: true, value: { treasury: linked } }); + assert.deepEqual(await agent.deposit("NEAR"), { ok: true, value: { deposit } }); + assert.deepEqual(await agent.link("funds.near"), { ok: true, value: {} }); + assert.deepEqual(await agent.unlink(), { ok: true, value: {} }); + assert.deepEqual(await agent.setConfidential(true), { ok: true, value: {} }); + assert.deepEqual(server.sent.filter(request => request.method === "POST").map(request => [request.path, JSON.parse(request.body)]), [ + [`${base}/deposit`, { asset: "NEAR" }], + [`${base}/treasury/link`, { accountId: "funds.near" }], + [`${base}/treasury/unlink`, {}], + [`${base}/treasury/confidential`, { confidential: true }], + ]); + assert.throws(() => agent.link("Not An Account"), "held to the server's schema before anything is sent"); + assert.throws(() => agent.deposit("ETH" as "USDC")); +}); diff --git a/web/test/proposals.e2e.ts b/web/test/proposals.e2e.ts index bb1486a..41c8a1a 100644 --- a/web/test/proposals.e2e.ts +++ b/web/test/proposals.e2e.ts @@ -31,6 +31,8 @@ let stack: Awaited>, target: Stack, owner: string; let server: Server, origin: string, browser: Browser, page: Page; /** Requests the page made anywhere but this server and the wallet's boundary: none are expected. */ const unexpected: string[] = []; +/** What the owner's wallet was asked to send, in order: it sends nothing on the stack, whose chain takes no transactions. */ +const sent: { signerId: string; receiverId: string; actions: { params: { methodName: string; args: Record; deposit: string } }[] }[] = []; before(async () => { stack = await startStack({ args: ["--no-browser", "--app", "treasury-test"] }); @@ -54,7 +56,9 @@ before(async () => { }); browser = await chromium.launch(); - const context = await browser.newContext(); + // Reduced motion, which the page honours by dropping transitions: a button whose colours are still fading when the + // accessibility check reads them (here, the deposit button turning outline once an address is shown) fails it at random. + const context = await browser.newContext({ reducedMotion: "reduce" }); await context.route("**/*", async route => { const url = new URL(route.request().url()); if (url.origin === origin) return route.continue(); @@ -67,13 +71,15 @@ before(async () => { }); // The owner's wallet signs as the stack's owner, through its control: never a module of the page replaced. await context.exposeFunction("__localSign", (payload: { message: string; recipient: string; nonce: string }) => signOwner(target, payload)); + // The wallet's success for a transaction: recorded, never sent. + await context.exposeFunction("__localSend", (transaction: (typeof sent)[number]) => { sent.push(transaction); return { status: { SuccessValue: "" } }; }); await context.addInitScript(({ account, publicKey }) => { const accounts = [{ accountId: account, publicKey }]; const wallet = { manifest: { id: "local-test-wallet", name: "Local test wallet", type: "injected", icon: "data:image/svg+xml,", features: { signMessage: true } }, signIn: async () => accounts, getAccounts: async () => accounts, signOut: async () => {}, signMessage: (payload: { message: string; recipient: string; nonce: Uint8Array }) => (window as unknown as { __localSign: (p: unknown) => Promise }).__localSign({ ...payload, nonce: btoa(String.fromCharCode(...new Uint8Array(payload.nonce))) }), - signAndSendTransaction: async () => { throw new Error("This test wallet only signs messages"); } }; + signAndSendTransaction: (transaction: unknown) => (window as unknown as { __localSend: (t: unknown) => Promise }).__localSend(transaction) }; window.addEventListener("near-selector-ready", () => window.dispatchEvent(new CustomEvent("near-wallet-injected", { detail: wallet }))); localStorage.setItem("proposals:selected-wallet:mainnet", "local-test-wallet"); }, { account: owner, publicKey: state.testPublicKey }); @@ -231,3 +237,39 @@ test("a connected agent's page shows its portfolio from the owner reads: holding assert.deepEqual(await axe(), []); assert.deepEqual(unexpected, []); }); + +test("the owner links their own account to fund the agent, sends exactly the reviewed USDC transfer from their wallet, gets a deposit address, and unlinks", { timeout: 90_000 }, async () => { + await page.goto(`${origin}/agents/${target.agent}`); + const funding = page.getByRole("region", { name: "Funding" }); + await signInIfAsked(funding); + await visible(funding.getByText("No funding account is linked. Link the NEAR account that funds this agent")); + assert.deepEqual(await axe(), [], "nothing linked"); + await funding.getByRole("button", { name: `Use ${owner}` }).click(); + await visible(funding.getByRole("status").filter({ hasText: `Funded from ${owner}.` })); + + await funding.getByLabel("Funding amount").fill("1.5"); + await funding.getByRole("button", { name: "Review transfer" }).click(); + const review = funding.getByRole("group", { name: "Funding transfer to sign" }); + await visible(review); + assert.match(await review.innerText(), /exactly 1\.5 USDC from/); + const address = (await review.locator("code").first().innerText()).trim(); + assert.deepEqual(await axe(), [], "the review"); + await review.getByRole("button", { name: "Send" }).click(); + await visible(funding.getByRole("status").filter({ hasText: "Sent. The portfolio updates once the deposit lands." })); + assert.equal(sent.length, 1, "sent once"); + const [transfer] = sent; + assert.equal(transfer!.signerId, owner, "from the funding account"); + const ft = transfer!.actions.find(action => action.params.methodName === "ft_transfer")!; + assert.deepEqual([ft.params.args.receiver_id, ft.params.args.amount], [address, "1500000"], "exactly the address and amount reviewed"); + + await funding.getByRole("button", { name: "Get a deposit address" }).click(); + await visible(funding.getByText("Deposit address", { exact: true })); + assert.deepEqual(await axe(), [], "a deposit address"); + + await funding.getByRole("button", { name: "Unlink" }).click(); + const unlink = dialog("Unlink the funding account?"); + await visible(unlink); + await unlink.getByRole("button", { name: "Unlink" }).click(); + await visible(funding.getByText("No funding account is linked. Link the NEAR account that funds this agent")); + assert.deepEqual(unexpected, [], "the page reached nothing but this server and the wallet's boundary"); +});