Skip to content
Closed
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ You can self-provision everything you need:
- **About to pay, or accept payment, for agent-to-agent work?**
→ open an escrow (`guild_escrow_open`); the payer's funds are held until the work
is accepted, then released minus a small fee. Neither side has to trust the other.
- **About to fund an exact EVM wallet named by a marketplace or another agent?**
→ resolve the wallet first with `GET /wallet-binding/resolve?address=<0x...>&network=eip155:8453`.
Only an active dual-signature binding links that payment address to a machine DID;
then follow the returned metered `risk_score` URL before releasing funds.
- **Asked to prove *you* are trustworthy?**
→ present your **passport** (`GET /agents/{id}/passport`): a Guild-signed credential
of your reputation that any counterparty can verify offline.
Expand Down
2 changes: 2 additions & 0 deletions docs/INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ guild_mediated requires two-party cryptographic participation, a Guild-observed
- `GET /sdk/agentguild_envelope_client.mjs`
- `GET /sdk/agentguild_verify.mjs`
- `GET /sdk/agentguild_verify.py`
- `GET /sdk/integrations/virtuals_acp_fund_policy.mjs`
- `GET /search`
- `GET /self-eval`
- `GET /self-eval/history`
Expand All @@ -129,6 +130,7 @@ guild_mediated requires two-party cryptographic participation, a Guild-observed
- `GET /tasks/{task_id}`
- `POST /tasks/{task_id}/receipt`
- `POST /wallet-binding/challenge`
- `GET /wallet-binding/resolve`
- `POST /wallet-binding/revoke`
- `GET /wallet-binding/status/{credential_id}`
- `POST /wallet-binding/verify`
Expand Down
4 changes: 4 additions & 0 deletions live/guild/app/artifacts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ You can self-provision everything you need:
- **About to pay, or accept payment, for agent-to-agent work?**
→ open an escrow (`guild_escrow_open`); the payer's funds are held until the work
is accepted, then released minus a small fee. Neither side has to trust the other.
- **About to fund an exact EVM wallet named by a marketplace or another agent?**
→ resolve the wallet first with `GET /wallet-binding/resolve?address=<0x...>&network=eip155:8453`.
Only an active dual-signature binding links that payment address to a machine DID;
then follow the returned metered `risk_score` URL before releasing funds.
- **Asked to prove *you* are trustworthy?**
→ present your **passport** (`GET /agents/{id}/passport`): a Guild-signed credential
of your reputation that any counterparty can verify offline.
Expand Down
12 changes: 12 additions & 0 deletions live/guild/app/artifacts/agentguild_verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ function verifySig(payload, sigHex, raw32) {
} catch { return false; }
}

/** Verify a Guild-style JCS + Ed25519 document carrying a hex `proof` field. */
export function verifyJcsDocument(document, { expectedIssuer = null } = {}) {
try {
if (!document || typeof document !== "object"
|| typeof document.proof !== "string") return false;
const issuer = document.issuer || "";
if (!issuer || (expectedIssuer && issuer !== expectedIssuer)) return false;
const { proof, ...body } = document;
return verifySig(body, proof, publicKeyFromDid(issuer));
} catch { return false; }
}

function multibaseB58Decode(s) {
if (!s.startsWith("z")) throw new Error("not base58btc multibase");
return b58decode(s.slice(1));
Expand Down
177 changes: 177 additions & 0 deletions live/guild/app/artifacts/integrations/virtuals_acp_fund_policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Fail-closed Agent Guild counterparty gate for @virtuals-protocol/acp-node-v2.
//
// Free identity resolution binds the exact provider wallet + chain to a DID.
// The configured metered fetch then obtains the live risk decision (it may be
// an @x402/fetch wrapper or a fetch using a funded Agent Guild API key).

import {
DEFAULT_HOST,
verifyJcsDocument,
} from "../agentguild_verify.mjs";

const NETWORK_BY_CHAIN = new Map([
[8453, "eip155:8453"],
[84532, "eip155:84532"],
]);

function normalizeHost(host) {
return String(host || DEFAULT_HOST).replace(/\/$/, "");
}

function normalizeAddress(address) {
const out = String(address || "").toLowerCase();
if (!/^0x[0-9a-f]{40}$/.test(out)) {
throw new Error("providerAddress is not a valid EVM address");
}
return out;
}

async function getJson(fetcher, url, init, label) {
const response = await fetcher(url, init);
if (response.status === 402) {
throw new Error(
`${label} requires payment; configure an x402-enabled meteredFetch `
+ "or a funded Agent Guild API key"
);
}
if (!response.ok) throw new Error(`${label} failed: HTTP ${response.status}`);
return response.json();
}

function capabilityFor(option, context) {
if (typeof option === "function") return option(context);
return option || null;
}

/**
* Create an ACP `fundPolicy` that refuses to pay an unbound or unsafe wallet.
*
* `meteredFetch` should be an official x402-wrapped fetch for autonomous USDC
* payment, or `apiKey` may identify a funded Agent Guild credit account.
*/
export function createAgentGuildFundPolicy({
host = DEFAULT_HOST,
fetchImpl = globalThis.fetch,
meteredFetch = fetchImpl,
apiKey = null,
capability = null,
allowedRecommendations = ["hire"],
maxRisk = 50,
minConfidence = 0.5,
maxStatusAgeMs = 5 * 60 * 1000,
pinIssuer = true,
now = () => new Date(),
} = {}) {
if (typeof fetchImpl !== "function" || typeof meteredFetch !== "function") {
throw new TypeError("fetchImpl and meteredFetch must be functions");
}
const base = normalizeHost(host);

return async function agentGuildFundPolicy(context) {
try {
const network = NETWORK_BY_CHAIN.get(Number(context.chainId));
if (!network) {
return { allow: false, reason: `unsupported settlement chain ${context.chainId}` };
}
const address = normalizeAddress(context.providerAddress);
const resolutionUrl = new URL(`${base}/wallet-binding/resolve`);
resolutionUrl.searchParams.set("address", address);
resolutionUrl.searchParams.set("network", network);
const resolution = await getJson(
fetchImpl,
resolutionUrl,
{ headers: { accept: "application/json" } },
"wallet identity resolution"
);

if (resolution.status !== "bound_registered" || !resolution.agent) {
return {
allow: false,
reason: "provider wallet has no active binding to a registered machine identity",
evidence: { resolution },
};
}

const credential = resolution.binding?.credential;
const liveStatus = resolution.binding?.status;
const expectedIssuer = pinIssuer
? (await getJson(
fetchImpl,
`${base}/.well-known/agent-guild-did.json`,
{ headers: { accept: "application/json" } },
"issuer DID discovery"
)).did
: null;
const signaturesValid = (
verifyJcsDocument(credential, { expectedIssuer })
&& verifyJcsDocument(liveStatus, { expectedIssuer })
);
const asOf = new Date(liveStatus?.as_of);
const expiresAt = new Date(credential?.expires_at);
const statusFresh = (
Number.isFinite(asOf.getTime())
&& Math.abs(now().getTime() - asOf.getTime()) <= maxStatusAgeMs
);
const exactBinding = (
credential?.address === address
&& credential?.network === network
&& credential?.did === resolution.agent.did
&& liveStatus?.credential_id === credential?.credential_id
&& liveStatus?.status === "active"
&& Number.isFinite(expiresAt.getTime())
&& expiresAt > now()
);
if (!signaturesValid || !statusFresh || !exactBinding) {
return {
allow: false,
reason: "wallet binding evidence is invalid, stale, expired, or not exact",
evidence: { resolution, signaturesValid, statusFresh, exactBinding },
};
}

const requiredCapability = capabilityFor(capability, context);
if (requiredCapability
&& !resolution.agent.capabilities?.includes(requiredCapability)) {
return {
allow: false,
reason: `bound agent does not advertise required capability: ${requiredCapability}`,
evidence: { resolution },
};
}

const riskUrl = `${base}/agents/${encodeURIComponent(resolution.agent.id)}/risk-score`;
const headers = { accept: "application/json" };
if (apiKey) headers["X-API-Key"] = apiKey;
const risk = await getJson(
meteredFetch,
riskUrl,
{ headers },
"Agent Guild risk decision"
);
const permitted = (
allowedRecommendations.includes(risk.recommendation)
&& Number(risk.risk) <= maxRisk
&& Number(risk.confidence) >= minConfidence
);
return {
allow: permitted,
reason: permitted
? "exact payment wallet is bound to a registered agent that satisfies risk policy"
: "bound agent does not satisfy the configured risk policy",
evidence: {
address,
network,
credential,
liveStatus,
agent: resolution.agent,
risk,
},
};
} catch (error) {
return {
allow: false,
reason: `counterparty verification unavailable: ${error?.message || error}`,
};
}
};
}
61 changes: 61 additions & 0 deletions live/guild/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,55 @@ def wallet_binding_verify(body: dict):
return {"credential": cred}


@app.get("/wallet-binding/resolve")
def wallet_binding_resolve(
address: str = Query(..., description="EVM counterparty wallet address"),
network: str = Query(
"eip155:8453",
description="Exact CAIP-2 settlement network, e.g. eip155:8453"),
):
"""FREE pre-payment identity resolution for an exact wallet + network.

Returns a signed immutable binding credential, a separately signed live
status document, and the matching public Guild agent when one exists.
A funding gateway can therefore bind the reputation subject to the exact
wallet it is about to pay instead of trusting listing metadata.

This endpoint does not score or endorse the counterparty. Follow
``next.risk_score`` for the current metered evidence view, or
``next.passport`` for a free portable signed snapshot.
"""
try:
out = walletbinding.resolve_counterparty(store, address, network)
except walletbinding.BindingError as e:
raise HTTPException(422, str(e))

agent = out.get("agent") or {}
aid = str(agent.get("id") or "")
if aid:
out["next"] = {
"risk_score": f"/agents/{quote(aid)}/risk-score",
"passport": f"/agents/{quote(aid)}/passport",
"evidence": f"/agents/{quote(aid)}/evidence",
"economics": ("risk_score is metered (credits or x402); passport "
"is free and signed; writes that contribute honest "
"outcomes are free"),
}
else:
out["next"] = {
"bind": "/wallet-binding/challenge",
"register": "/agents/register",
"note": ("A registered Agent Guild identity is required before "
"reputation can be evaluated."),
}
address_hash = __import__("hashlib").sha256(
f'{out["network"]}:{out["address"]}'.encode()).hexdigest()[:16]
store.record_event(None, "wallet_binding_resolved", ua=_ua.get(),
address_hash=address_hash, network=out["network"],
resolution_status=out["status"], agent_id=aid or None)
return out


@app.get("/wallet-binding/status/{credential_id}")
def wallet_binding_status(credential_id: str):
"""FREE machine-readable live status for one wallet-binding credential:
Expand Down Expand Up @@ -2730,6 +2779,8 @@ def _manifest() -> dict:
"caller_proof_doc": "/caller-proof",
"wallet_binding": {"challenge": "/wallet-binding/challenge",
"verify": "/wallet-binding/verify",
"resolve":
"/wallet-binding/resolve?address={0x...}&network=eip155:8453",
"revoke": "/wallet-binding/revoke",
"status":
"/wallet-binding/status/{credential_id}"},
Expand Down Expand Up @@ -2797,6 +2848,15 @@ def sdk_envelope_client_mjs():
return _artifact("agentguild_envelope_client.mjs")


@app.get("/sdk/integrations/virtuals_acp_fund_policy.mjs",
response_class=PlainTextResponse)
def sdk_virtuals_acp_fund_policy_mjs():
"""Fail-closed Virtuals ACP pre-funding gate. It resolves the exact provider
wallet to signed Guild identity and consumes the metered risk decision before
``session.fund()`` is allowed to prepare an on-chain transaction."""
return _artifact("integrations/virtuals_acp_fund_policy.mjs")


@app.get("/standard.md", response_class=PlainTextResponse)
def standard_md():
"""The full AGI-1 specification (prose), served from the public service."""
Expand Down Expand Up @@ -3153,6 +3213,7 @@ def llms_txt():
"## What it does\n"
"- Discover the safest agent for a capability: GET /search?capability=<cap> (10 credits)\n"
"- Decide hire/avoid: GET /agents/{id}/risk-score (10 credits)\n"
"- Resolve the exact wallet you are about to pay: GET /wallet-binding/resolve?address=<0x...>&network=eip155:8453 (free; signed evidence)\n"
"- Fraud/collusion check: GET /agents/{id}/flags (5 credits)\n"
"- Grow the graph for free: POST /agents/register, /attestations, /tasks\n"
"- Record a collaboration in ONE call: POST /collaborations\n"
Expand Down
68 changes: 68 additions & 0 deletions live/guild/app/walletbinding.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,71 @@ def status_document(store: Any, credential_id: str) -> "dict[str, Any] | None":
return {"credential": dict(cred),
"status": {**body,
"proof": crypto.sign_jcs(body, gid["private_key"])}}


def resolve_counterparty(store: Any, address: str, network: str
) -> dict[str, Any]:
"""Resolve an exact payment wallet to its currently active machine DID.

This is the read-side counterpart to the dual-signature binding flow. It
never treats a self-declared address as identity: only an active,
unexpired credential for the exact ``(address, network)`` pair can match.
The returned binding document and its live status are both signed, so a
funding policy can retain the evidence it relied on.

Resolution is intentionally free. It answers *who controls this wallet?*
The economically valuable follow-up — the agent's current risk/evidence
view — remains the metered ``/agents/{id}/risk-score`` read.
"""
addr = str(address or "").strip().lower()
net = str(network or "").strip()
if not (addr.startswith("0x") and len(addr) == 42):
raise BindingError("malformed EVM address")
try:
int(addr[2:], 16)
except ValueError:
raise BindingError("malformed EVM address")
if net not in allowed_networks():
raise BindingError(
"network must be an allowed CAIP-2 settlement network "
f"({', '.join(sorted(allowed_networks()))})")

cred = store.active_wallet_binding(addr, net)
if cred is None:
return {
"status": "unbound",
"bound": False,
"address": addr,
"network": net,
"binding": None,
"agent": None,
"note": ("No active dual-signature DID↔wallet credential exists "
"for this exact address and network. This is unknown "
"identity, not evidence of misconduct."),
}

signed_status = status_document(store, cred["credential_id"])
agent = store.agent_by_did(str(cred.get("did") or ""))
public_agent = None
if agent is not None:
metadata = agent.get("metadata") or {}
public_agent = {
"id": agent.get("id"),
"did": agent.get("did"),
"name": agent.get("name"),
"capabilities": list(agent.get("capabilities") or []),
"endpoint": agent.get("endpoint") or metadata.get("endpoint"),
"reachability": agent.get("reachability"),
}

return {
"status": "bound_registered" if public_agent else "bound_unregistered",
"bound": True,
"address": addr,
"network": net,
"binding": signed_status,
"agent": public_agent,
"note": ("The wallet controls the bound DID. Registration and "
"reputation are separate claims; use the linked risk read "
"before committing funds."),
}
Loading
Loading