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
2 changes: 1 addition & 1 deletion agent/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def owner_funds_of(settings: Settings, payer: Payer | None) -> OwnerFunds | None
return None
kit = UnifiedBalance(settings.circle_api_key, settings.circle_entity_secret)
facts = cache(kit.chains) # one Node run for the chains' facts, then reused
return OwnerFunds(kit, payer, OwnerWallets(payer.circle, facts))
return OwnerFunds(kit, payer, OwnerWallets(payer.circle, facts), settings.signer_address)


def brain_setup(
Expand Down
29 changes: 14 additions & 15 deletions dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,8 @@ function accountMenu(open) {
$("account-button").setAttribute("aria-expanded", String(open));
}

function readAddress(text, { allowEmpty = false } = {}) {
function readAddress(text) {
const value = String(text ?? "").trim();
if (!value && allowEmpty) return ZERO_ADDRESS;
if (!ethers.isAddress(value)) throw new Error("That is not a valid wallet address (0x followed by 40 characters).");
return ethers.getAddress(value);
}
Expand Down Expand Up @@ -273,6 +272,7 @@ function hideAgent() {
}

async function renderErp(isOwner, state) {
session.signer = null; // known once the owner is signed in
if (!isOwner) {
hideAgent();
return;
Expand All @@ -293,6 +293,7 @@ async function renderErp(isOwner, state) {
showErp("erp-signin");
return;
}
session.signer = hub.delegateOf(await hub.agentWallets(session.shop).catch(() => null), state.agent);
if (!shop.connected) {
hideAgent();
showErp("erp-form");
Expand Down Expand Up @@ -1047,13 +1048,11 @@ function wireActions() {
const maxPerPayment = parseUsdc(data.get("maxPerPayment"));
const weeklyCap = parseUsdc(data.get("weeklyCap"));
if (maxPerPayment > weeklyCap) throw new Error("The per-payment limit cannot be above the weekly limit.");
const agent = readAddress(data.get("agent"), { allowEmpty: true });
if (agent.toLowerCase() === session.account.toLowerCase()) {
throw new Error("The agent must be a different wallet from yours.");
}
notify("Create your shop contract: confirm it in MetaMask...");
const signer = await session.provider.getSigner();
const tx = await factory(signer).create(agent, maxPerPayment, weeklyCap);
notify("Preparing your shop's agent (sign in if MetaMask asks: it is free)...");
const wallets = await hub.newAgentWallets(signer); // the contract is created with its agent
notify("Create your shop contract: confirm it in MetaMask...");
const tx = await factory(signer).create(wallets.agent, maxPerPayment, weeklyCap);
notify("Creating your shop contract on Arc...");
await tx.wait();
settings(false);
Expand Down Expand Up @@ -1087,8 +1086,8 @@ function wireActions() {
});

onSubmit("funding-form", (data, button, form) => {
if (!session.agent) {
notify("Set the agent wallet in Settings first.", "error");
if (!session.signer) {
notify("Sign in first: it is a signature, not a transaction, and costs nothing.", "error");
return;
}
let maxPerMove;
Expand All @@ -1103,17 +1102,17 @@ function wireActions() {
}
const chain = data.get("chain");
unifiedAct(button, "Let the agent move USDC (one confirmation if your wallet batches, else three)", (provider) =>
unified.setUpChain(provider, chain, { agent: session.agent, maxPerMove, weeklyCap }),
unified.setUpChain(provider, chain, { agent: session.agent, signer: session.signer, maxPerMove, weeklyCap }),
).then(() => form.reset());
});

$("unified-authorize").addEventListener("click", (event) => {
if (!session.agent) {
notify("Set the agent wallet in Settings first.", "error");
if (!session.signer) {
notify("Sign in first: it is a signature, not a transaction, and costs nothing.", "error");
return;
}
const chain = $("unified-chain").value;
unifiedAct(event.currentTarget, "Authorize the agent", (provider) => unified.authorizeAgent(provider, chain, session.agent));
unifiedAct(event.currentTarget, "Authorize the agent", (provider) => unified.authorizeAgent(provider, chain, session.signer));
});

onSubmit("limits-form", (data, button, form) => {
Expand Down Expand Up @@ -1215,7 +1214,7 @@ function wireActions() {
button.disabled = true;
try {
notify("Sign in: confirm the signature in MetaMask (it is free)...");
await hub.signIn(await session.provider.getSigner(), session.shop);
await hub.signIn(await session.provider.getSigner());
notify("");
await renderShop();
} catch (error) {
Expand Down
40 changes: 36 additions & 4 deletions dashboard/hub.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,50 @@ export function forEthers(typed) {
return { domain: typed.domain, types, value: typed.message };
}

/** Sign-In with Ethereum (EIP-4361): the hub writes the message, the wallet signs it as is. */
export async function signIn(signer, shop, fetchImpl = fetch) {
/** Sign-In with Ethereum (EIP-4361): the hub writes the message, the wallet signs it as is. One
* signature covers every shop of the wallet, and creating a new one. */
export async function signIn(signer, fetchImpl = fetch) {
const address = await signer.getAddress();
const request = await call(`${shopPath(shop)}/sign-in-request`, { method: "POST", body: { address }, fetchImpl });
const request = await call(`${HUB_PATH}/owners/sign-in-request`, { method: "POST", body: { address }, fetchImpl });
const signature = await signer.signMessage(request.message);
return call(`${shopPath(shop)}/sign-in`, {
return call(`${HUB_PATH}/owners/sign-in`, {
method: "POST",
body: { nonce: request.nonce, signature },
fetchImpl,
});
}

/** The agent wallets of the owner's next shop ({agent, signer}): its contract is created with
* them. Signs in first when the owner has not yet. */
export async function newAgentWallets(signer, fetchImpl = fetch) {
const ask = () => call(`${HUB_PATH}/owners/agent-wallets`, { method: "POST", fetchImpl });
try {
return await ask();
} catch (error) {
if (!(error instanceof HubError && error.status === 401)) throw error;
}
await signIn(signer, fetchImpl);
return ask();
}

/** The contract's agent and the wallet that signs the owner's Unified Balance spends; null when
* the owner has to sign in first. */
export async function agentWallets(shop, fetchImpl = fetch) {
try {
return await call(`${shopPath(shop)}/agent`, { fetchImpl });
} catch (error) {
if (error instanceof HubError && error.status === 401) return null;
throw error;
}
}

/** Who the owner authorizes in Gateway: the agent's signer, or for an agent wallet that is not
* ours, the agent itself. */
export function delegateOf(wallets, contractAgent) {
if (!wallets) return null;
return wallets.ours ? wallets.signer : contractAgent;
}

/** The shop as the hub knows it, or null when the owner has to sign in first. */
export async function status(shop, fetchImpl = fetch) {
try {
Expand Down
5 changes: 1 addition & 4 deletions dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ <h2>Settings</h2>
<h3>Shop contract</h3>
<p class="hint">
You will be its owner. Only you can change these limits later. The agent can never go above them.
Your shop comes with its own agent: you do not set it up, and it never needs gas.
</p>
<form id="create-form" class="form" novalidate>
<label>
Expand All @@ -272,10 +273,6 @@ <h3>Shop contract</h3>
Most the agent may pay in a week (USDC)
<input name="weeklyCap" inputmode="decimal" placeholder="1000" required />
</label>
<label>
Agent wallet <span class="optional">(optional, you can add it later)</span>
<input name="agent" placeholder="0x..." autocomplete="off" spellcheck="false" />
</label>
<button class="btn btn-primary" type="submit">Create my shop contract</button>
</form>
</section>
Expand Down
36 changes: 32 additions & 4 deletions dashboard/test/hub.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,14 @@ test("ethers gets the types without the domain type", () => {
});

test("signing in signs the hub's EIP-4361 message as is and sends back its nonce, on this site only", async () => {
const shop = SHOP.toLowerCase();
const OWNER = "0x2137" + "0".repeat(32) + "0379";
const message = "app.embco.xyz wants you to sign in with your Ethereum account:\n" + OWNER;
const { impl, calls } = fakeFetch({
[`POST /api/shops/${shop}/sign-in-request`]: [200, { message, nonce: "n1n1n1n1" }],
[`POST /api/shops/${shop}/sign-in`]: [200, { owner: OWNER }],
"POST /api/owners/sign-in-request": [200, { message, nonce: "n1n1n1n1" }],
"POST /api/owners/sign-in": [200, { owner: OWNER }],
});
const signer = { getAddress: async () => OWNER, signMessage: async (text) => `sig:${text.length}` };
assert.deepEqual(await hub.signIn(signer, SHOP, impl), { owner: OWNER });
assert.deepEqual(await hub.signIn(signer, impl), { owner: OWNER });
assert.deepEqual(calls[0].body, { address: OWNER });
assert.deepEqual(calls[1].body, { nonce: "n1n1n1n1", signature: `sig:${message.length}` });
assert.ok(calls.every((c) => c.credentials === "same-origin"));
Expand Down Expand Up @@ -145,3 +144,32 @@ test("linking Telegram tells the owner exactly what to send, and to which bot",
"In Telegram, send this to @embco_agent_bot within 10 minutes: /link ABCD-EFGH");
assert.match(hub.linkInstruction({ code: "ABCD-EFGH", bot: null }), /your embco bot/);
});

test("a new shop's agent wallets sign the owner in once when needed, and only then", async () => {
const OWNER = "0x2137" + "0".repeat(32) + "0379";
const wallets = { agent: "0x" + "5c".repeat(20), signer: "0x" + "e0".repeat(20) };
let signedIn = false;
const signatures = [];
const answer = (status, body) => ({ ok: status < 400, status, json: async () => body });
const impl = async (path) => {
if (path === "/api/owners/sign-in-request") return answer(200, { message: "m", nonce: "n" });
if (path === "/api/owners/sign-in") {
signedIn = true;
return answer(200, { owner: OWNER });
}
if (path === "/api/owners/agent-wallets") return signedIn ? answer(200, wallets) : answer(401, { error: "sign in" });
return answer(404, {});
};
const signer = { getAddress: async () => OWNER, signMessage: async (text) => signatures.push(text) };
assert.deepEqual(await hub.newAgentWallets(signer, impl), wallets);
assert.deepEqual(await hub.newAgentWallets(signer, impl), wallets);
assert.equal(signatures.length, 1);
});

test("the owner authorizes the agent's signer in Gateway, or an outside agent itself", () => {
const agent = "0x" + "5c".repeat(20);
const signer = "0x" + "e0".repeat(20);
assert.equal(hub.delegateOf({ ours: true, signer }, agent), signer);
assert.equal(hub.delegateOf({ ours: false, signer: null }, agent), agent);
assert.equal(hub.delegateOf(null, agent), null);
});
13 changes: 13 additions & 0 deletions dashboard/test/unified.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,16 @@ test("setting up a chain is create, approve and authorize, skipping what is done
assert.ok(all[1].data.includes(base.funding.slice(2))); // approves the owner's own funding contract
assert.deepEqual(setupCalls({ ...base, create: false, delegate: false }).map((c) => c.to), [base.usdc]);
});

test("a gasless agent's funding contract is the agent's, and Gateway authorizes its signer", async () => {
const { setupCalls } = await import("../unified.js");
const agent = "0x" + "a9".repeat(20);
const signer = "0x" + "e0".repeat(20);
const [create, , authorize] = setupCalls({
factory: "0x" + "fa".repeat(20), funding: "0x" + "f1".repeat(20), usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
gateway: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", agent, signer,
maxPerMove: 1n, weeklyCap: 2n, create: true, delegate: true,
});
assert.ok(create.data.includes(agent.slice(2)) && !create.data.includes(signer.slice(2)));
assert.ok(authorize.data.includes(signer.slice(2)) && !authorize.data.includes(agent.slice(2)));
});
19 changes: 10 additions & 9 deletions dashboard/unified.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,17 @@ const BATCH_POLL_MS = 2000;
const BATCH_WAIT_MS = 180000;

/** The calls that let the agent move funds on a chain, in order: create the owner's ShopFunding
* (skipped when it exists), approve it, and authorize the agent in Gateway (skipped when it is). */
export function setupCalls({ factory, funding, create, usdc, gateway, agent, maxPerMove, weeklyCap, delegate }) {
* for the agent (skipped when it exists), approve it, and authorize the agent's signer in Gateway
* (skipped when it is). The signer is the agent itself for an older shop. */
export function setupCalls({ factory, funding, create, usdc, gateway, agent, signer = agent, maxPerMove, weeklyCap, delegate }) {
const calls = [];
if (create) {
const data = new ethers.Interface(FUNDING_FACTORY_ABI).encodeFunctionData("create", [agent, maxPerMove, weeklyCap]);
calls.push({ to: factory, data, value: "0x0" });
}
calls.push({ to: usdc, data: new ethers.Interface(USDC_ABI).encodeFunctionData("approve", [funding, AUTHORIZE_ALL]), value: "0x0" });
if (delegate) {
calls.push({ to: gateway, data: new ethers.Interface(GATEWAY_WALLET_ABI).encodeFunctionData("addDelegate", [usdc, agent]), value: "0x0" });
calls.push({ to: gateway, data: new ethers.Interface(GATEWAY_WALLET_ABI).encodeFunctionData("addDelegate", [usdc, signer]), value: "0x0" });
}
return calls;
}
Expand All @@ -113,7 +114,7 @@ async function waitForCalls(provider, id) {
* limits: create the owner's ShopFunding there, approve it, and authorize the agent in Gateway.
* One confirmation when the wallet batches calls (EIP-5792, MetaMask's smart account through
* EIP-7702); otherwise one per step. */
export async function setUpChain(provider, chain, { agent, maxPerMove, weeklyCap }, factories = FUNDING_FACTORIES) {
export async function setUpChain(provider, chain, { agent, signer: delegateAddress = agent, maxPerMove, weeklyCap }, factories = FUNDING_FACTORIES) {
const { UnifiedBalanceKit, resolveChainIdentifier } = await kit();
const definition = resolveChainIdentifier(chain);
const adapter = await adapterOn(provider, chain); // the wallet is now on that chain
Expand All @@ -123,10 +124,10 @@ export async function setUpChain(provider, chain, { agent, maxPerMove, weeklyCap
const factory = new ethers.Contract(factories[chain], FUNDING_FACTORY_ABI, browser);
const existing = (await factory.fundingsOf(owner)).at(-1);
const funding = existing ?? (await factory.predict(owner, agent, maxPerMove, weeklyCap));
const delegate = await new UnifiedBalanceKit().getDelegateStatus({ from: { adapter, chain }, delegateAddress: agent });
const delegate = await new UnifiedBalanceKit().getDelegateStatus({ from: { adapter, chain }, delegateAddress });
const calls = setupCalls({
factory: factories[chain], funding, create: !existing, usdc: definition.usdcAddress,
gateway: GATEWAY_WALLET_TESTNET, agent, maxPerMove, weeklyCap, delegate: delegate === "none",
gateway: GATEWAY_WALLET_TESTNET, agent, signer: delegateAddress, maxPerMove, weeklyCap, delegate: delegate === "none",
});
const chainId = ethers.toQuantity(definition.chainId);
const capabilities = await provider.request({ method: "wallet_getCapabilities", params: [owner, [chainId]] })
Expand All @@ -144,9 +145,9 @@ export async function setUpChain(provider, chain, { agent, maxPerMove, weeklyCap
return funding;
}

/** Let the agent's wallet spend the owner's Unified Balance on this chain (only to the owner). */
export async function authorizeAgent(provider, chain, agent) {
/** Let the agent's signer spend the owner's Unified Balance on this chain (only to the owner). */
export async function authorizeAgent(provider, chain, signer) {
const { UnifiedBalanceKit } = await kit();
const adapter = await adapterOn(provider, chain);
return new UnifiedBalanceKit().addDelegate({ from: { adapter, chain }, delegateAddress: agent });
return new UnifiedBalanceKit().addDelegate({ from: { adapter, chain }, delegateAddress: signer });
}
17 changes: 10 additions & 7 deletions docs/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ and the agent never gets admin rights.

!!! info "Before you start"
- Your shop is already created in the [dashboard](https://app.embco.xyz): your wallet is
connected and your shop contract exists (create it in **Settings**).
connected and your shop contract exists (create it in **Settings**). Creating it asks for
one free signature and one transaction, and the shop comes with its own agent: you never
set it up or send it gas.
- You can sign in to ERPNext as an administrator (System Manager) to make the changes below.
- Your ERPNext opens with an `https://` address. embco is tested with ERPNext v16, including
Frappe Cloud.
Expand Down Expand Up @@ -67,8 +69,8 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next

## Step 5: Connect in the dashboard

1. Open [app.embco.xyz](https://app.embco.xyz) and **Sign in** with your wallet. It is a
signature, not a transaction: it costs nothing.
1. Open [app.embco.xyz](https://app.embco.xyz) and **Sign in** with your wallet, if you did
not when you created the shop. It is a signature, not a transaction: it costs nothing.
2. In **Your ERPNext**, fill in:

| Field | What to write |
Expand All @@ -91,8 +93,8 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next
| Payments | ERPNext can draft a payment for an invoice (nothing is saved) |
| Wallets | How many supplier wallets were found at `USDC on Arc` |

4. Click **Make it the agent**. This lets your shop's agent wallet pay approved suppliers, within
the limits you set.
4. A shop created before its agent came with it shows **Make it the agent**: click it once. This
lets your shop's agent wallet pay approved suppliers, within the limits you set.

## What happens next

Expand All @@ -110,8 +112,9 @@ then you approve it in the dashboard (see [What happens next](#what-happens-next
account, EIP-5792) asks once; otherwise once per step. Your USDC stays in your wallet there.
When an invoice needs more than your wallet on Arc holds, the agent can move it into your
Unified Balance and bring it to your wallet on Arc, choosing which chains it comes from. It
never sends it anywhere else, and your limits still apply. Each chain asks for its own gas token
(test ETH on Base Sepolia and Ethereum Sepolia), for you once and for the agent's wallet.
never sends it anywhere else, and your limits still apply. Your wallet needs that chain's gas
token for this setup (test ETH on Base Sepolia and Ethereum Sepolia). The agent needs none: its
network fees are paid by Circle Gas Station.
- **Telegram (optional):** in the dashboard, **Connect Telegram** gives a code to send to the
embco bot. Then `/status` says how the agent is, `/pause` pauses payments while the agent keeps
watching, and `/stop` turns the agent off and pauses the shop contract. Only the dashboard
Expand Down
Loading
Loading