Skip to content
Open
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
226 changes: 226 additions & 0 deletions app/account/[contract]/OwnerActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"use client";

import { useEffect, useState } from "react";
import { Cl } from "@stacks/transactions";

type Network = "mainnet" | "testnet";

interface Permission {
fn: string;
label: string;
desc: string;
}

// The four owner-only setters on aibtc-acct. Each takes a single bool and is
// gated by `(asserts! (is-owner) ...)` on-chain — the agent (MCP) wallet
// cannot sign these, only the account owner can.
const PERMISSIONS: Permission[] = [
{
fn: "set-agent-can-manage-assets",
label: "Manage assets",
desc: "Deposit and withdraw STX and tokens. Withdrawals always route back to you, the owner.",
},
{
fn: "set-agent-can-use-proposals",
label: "Use proposals",
desc: "Create, vote on, conclude, and veto DAO action proposals.",
},
{
fn: "set-agent-can-approve-revoke-contracts",
label: "Approve / revoke contracts",
desc: "Add or remove contracts from the account's allowlist (voting, swap, token).",
},
{
fn: "set-agent-can-buy-sell-assets",
label: "Buy / sell assets",
desc: "Trade DAO tokens through approved swap adapters.",
},
];

type Result =
| { fn: string; enabled: boolean; txid: string }
| { fn: string; enabled: boolean; error: string };

function shortAddr(addr: string): string {
return `${addr.slice(0, 5)}…${addr.slice(-4)}`;
}

export default function OwnerActions({
contract,
network,
}: {
contract: string;
network: Network;
}) {
const [connected, setConnected] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); // `${fn}:${enabled}`
const [result, setResult] = useState<Result | null>(null);

// @stacks/connect touches localStorage, so it is imported lazily (browser
// only) — never during server render.
useEffect(() => {
let cancelled = false;
(async () => {
const { isConnected, getLocalStorage } = await import("@stacks/connect");
if (!cancelled && isConnected()) {
setConnected(getLocalStorage()?.addresses?.stx?.[0]?.address ?? null);
}
})();
return () => {
cancelled = true;
};
}, []);

async function ensureConnected(): Promise<void> {
const { isConnected, connect, getLocalStorage } = await import("@stacks/connect");
if (!isConnected()) {
await connect();
}
setConnected(getLocalStorage()?.addresses?.stx?.[0]?.address ?? null);
}

async function handleConnect() {
try {
await ensureConnected();
} catch {
/* user dismissed the wallet picker */
}
}

async function handleDisconnect() {
const { disconnect } = await import("@stacks/connect");
disconnect();
setConnected(null);
}

async function handleToggle(fn: string, enabled: boolean) {
const key = `${fn}:${enabled}`;
setResult(null);
setBusy(key);
try {
await ensureConnected();
const { request } = await import("@stacks/connect");
const res = await request("stx_callContract", {
contract: contract as `${string}.${string}`,
functionName: fn,
functionArgs: [Cl.bool(enabled)],
network,
postConditionMode: "deny",
postConditions: [],
});
if (!res.txid) throw new Error("Wallet did not return a transaction id.");
setResult({ fn, enabled, txid: res.txid });
} catch (e) {
setResult({
fn,
enabled,
error: e instanceof Error ? e.message : "Signing was cancelled or failed.",
});
} finally {
setBusy(null);
}
}

const explorerTx = (txid: string) =>
`https://explorer.hiro.so/txid/${txid}?chain=${network}`;

return (
<>
{/* Connect bar */}
<div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 p-4">
{connected ? (
<>
<span className="text-sm text-white/70">
Connected as{" "}
<span className="font-mono text-white" title={connected}>
{shortAddr(connected)}
</span>
</span>
<button
type="button"
onClick={handleDisconnect}
className="rounded-md border border-white/15 px-3 py-1.5 text-sm text-white/70 transition-colors hover:text-white"
>
Disconnect
</button>
</>
) : (
<>
<span className="text-sm text-white/60">
Connect the owner wallet to sign.
</span>
<button
type="button"
onClick={handleConnect}
className="rounded-md bg-[#F7931A] px-4 py-1.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
Connect wallet
</button>
</>
)}
</div>

{/* Permission cards */}
<div className="space-y-4">
{PERMISSIONS.map((p) => {
const grantBusy = busy === `${p.fn}:true`;
const revokeBusy = busy === `${p.fn}:false`;
const rowResult = result?.fn === p.fn ? result : null;
return (
<section
key={p.fn}
className="rounded-lg border border-white/10 bg-white/5 p-6"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-lg font-semibold text-white">{p.label}</h2>
<p className="mt-1 text-sm text-white/60">{p.desc}</p>
<p className="mt-2 font-mono text-xs text-white/35">{p.fn}</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
disabled={busy !== null}
onClick={() => handleToggle(p.fn, true)}
className="rounded-md bg-[#F7931A] px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{grantBusy ? "Signing…" : "Grant"}
</button>
<button
type="button"
disabled={busy !== null}
onClick={() => handleToggle(p.fn, false)}
className="rounded-md border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-40"
>
{revokeBusy ? "Signing…" : "Revoke"}
</button>
</div>
</div>

{rowResult && (
<div className="mt-4 border-t border-white/10 pt-4 text-sm">
{"txid" in rowResult ? (
<p className="text-white/70">
{rowResult.enabled ? "Granting" : "Revoking"} submitted —{" "}
<a
href={explorerTx(rowResult.txid)}
target="_blank"
rel="noopener noreferrer"
className="text-[#7DA2FF] underline underline-offset-2 hover:text-white"
>
view transaction
</a>
. It takes effect once confirmed on-chain.
</p>
) : (
<p className="text-red-400">{rowResult.error}</p>
)}
</div>
)}
</section>
);
})}
</div>
</>
);
}
81 changes: 81 additions & 0 deletions app/account/[contract]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import Navbar from "../../components/Navbar";
import Footer from "../../components/Footer";
import AnimatedBackground from "../../components/AnimatedBackground";
import OwnerActions from "./OwnerActions";

// Stacks contract id: <c32-address>.<contract-name>
const CONTRACT_RE = /^S[PMNT][0-9A-Z]{38,40}\.[a-zA-Z][a-zA-Z0-9_-]{0,127}$/;

function parseContract(
raw: string
): { contract: string; network: "mainnet" | "testnet" } | null {
const contract = decodeURIComponent(raw);
if (!CONTRACT_RE.test(contract)) return null;
const network =
contract.startsWith("ST") || contract.startsWith("SN")
? "testnet"
: "mainnet";
return { contract, network };
}

export async function generateMetadata({
params,
}: {
params: Promise<{ contract: string }>;
}): Promise<Metadata> {
const { contract } = await params;
return {
title: "Agent Account — Owner Actions",
description: `Connect your wallet and set agent permissions for ${decodeURIComponent(contract)}.`,
// Per-account action pages are reached via a handoff link, not search.
robots: { index: false, follow: false },
};
}

export default async function AccountOwnerPage({
params,
}: {
params: Promise<{ contract: string }>;
}) {
const { contract: raw } = await params;
const parsed = parseContract(raw);

return (
<div className="relative min-h-screen">
<AnimatedBackground />
<Navbar />

<div className="relative mx-auto max-w-3xl px-4 pb-20 pt-24">
<h1 className="mb-3 text-4xl font-bold text-white md:text-5xl">
Agent Account — Owner Actions
</h1>

{parsed ? (
<>
<p className="mb-2 text-lg text-white/60">
You are the owner of this agent account. Connect your wallet and
grant or revoke what the agent is allowed to do. Only the owner
can change these.
</p>
<p className="mb-10 font-mono text-xs break-all text-white/40">
{parsed.contract}
</p>
<OwnerActions contract={parsed.contract} network={parsed.network} />
</>
) : (
<p className="mt-6 rounded-lg border border-white/10 bg-white/5 p-6 text-white/70">
<span className="text-red-400">Invalid contract id.</span> Expected
a Stacks agent-account contract like{" "}
<span className="font-mono text-white/80">
SP….aibtc-acct-…
</span>
. Open this page from the link your agent (MCP) provided.
</p>
)}
</div>

<Footer />
</div>
);
}
10 changes: 10 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_BASE_PATH: isGitHubPages ? "/landing-page" : "",
},
webpack: (config) => {
// @stacks/connect pulls in WalletConnect, whose pino logger optionally
// requires pino-pretty (dev-only pretty printing, never used in prod).
// Silence the benign "can't resolve pino-pretty" build warning.
config.ignoreWarnings = [
...(config.ignoreWarnings ?? []),
{ module: /node_modules\/pino/, message: /pino-pretty/ },
];
return config;
},
};

export default nextConfig;
Loading
Loading