feat: added contract interactivity 96114f26
Steve · 2025-08-31 13:38 7 file(s) · +169 −18
bun.lock +25 −1
28 28
        "server": "workspace:*",
29 29
        "shared": "workspace:*",
30 30
        "tailwindcss": "^4.1.10",
31 -
        "viem": "^2.21.1",
31 +
        "viem": "^2.36.0",
32 32
      },
33 33
      "devDependencies": {
34 34
        "@eslint/js": "^9.28.0",
773 773
774 774
    "client/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
775 775
776 +
    "client/viem": ["viem@2.36.0", "", { "dependencies": { "@noble/curves": "1.9.6", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.0.8", "isows": "1.0.7", "ox": "0.9.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-Xz7AkGtR43K+NY74X2lBevwfRrsXuifGUzt8QiULO47NXIcT7g3jcA4nIvl5m2OTE5v8SlzishwXmg64xOIVmQ=="],
777 +
778 +
    "contracts/viem": ["viem@2.36.0", "", { "dependencies": { "@noble/curves": "1.9.6", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.0.8", "isows": "1.0.7", "ox": "0.9.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-Xz7AkGtR43K+NY74X2lBevwfRrsXuifGUzt8QiULO47NXIcT7g3jcA4nIvl5m2OTE5v8SlzishwXmg64xOIVmQ=="],
779 +
776 780
    "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
777 781
778 782
    "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
780 784
    "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
781 785
782 786
    "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
787 +
788 +
    "server/viem": ["viem@2.36.0", "", { "dependencies": { "@noble/curves": "1.9.6", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.0.8", "isows": "1.0.7", "ox": "0.9.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-Xz7AkGtR43K+NY74X2lBevwfRrsXuifGUzt8QiULO47NXIcT7g3jcA4nIvl5m2OTE5v8SlzishwXmg64xOIVmQ=="],
783 789
784 790
    "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
785 791
796 802
    "@babel/helper-module-imports/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
797 803
798 804
    "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
805 +
806 +
    "client/viem/@noble/curves": ["@noble/curves@1.9.6", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA=="],
807 +
808 +
    "client/viem/ox": ["ox@0.9.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.8", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-NVI0cajROntJWtFnxZQ1aXDVy+c6DLEXJ3wwON48CgbPhmMJrpRTfVbuppR+47RmXm3lZ/uMaKiFSkLdAO1now=="],
809 +
810 +
    "client/viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
811 +
812 +
    "contracts/viem/@noble/curves": ["@noble/curves@1.9.6", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA=="],
813 +
814 +
    "contracts/viem/ox": ["ox@0.9.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.8", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-NVI0cajROntJWtFnxZQ1aXDVy+c6DLEXJ3wwON48CgbPhmMJrpRTfVbuppR+47RmXm3lZ/uMaKiFSkLdAO1now=="],
815 +
816 +
    "contracts/viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
817 +
818 +
    "server/viem/@noble/curves": ["@noble/curves@1.9.6", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA=="],
819 +
820 +
    "server/viem/ox": ["ox@0.9.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.8", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-NVI0cajROntJWtFnxZQ1aXDVy+c6DLEXJ3wwON48CgbPhmMJrpRTfVbuppR+47RmXm3lZ/uMaKiFSkLdAO1now=="],
821 +
822 +
    "server/viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
799 823
800 824
    "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ=="],
801 825
client/orbiter.json (added) +6 −0
1 +
{
2 +
  "siteId": "fc1a09d0-a33a-4205-b746-9797226cdbc0",
3 +
  "domain": "natspec-ui",
4 +
  "buildCommand": "bun run build",
5 +
  "buildDir": "dist"
6 +
}
client/package.json +1 −1
20 20
    "server": "workspace:*",
21 21
    "shared": "workspace:*",
22 22
    "tailwindcss": "^4.1.10",
23 -
    "viem": "^2.21.1"
23 +
    "viem": "^2.36.0"
24 24
  },
25 25
  "devDependencies": {
26 26
    "@eslint/js": "^9.28.0",
client/src/App.tsx +135 −6
2 2
import { MarkdownUI } from '@markdown-ui/react';
3 3
import { Marked } from 'marked';
4 4
import { markedUiExtension } from '@markdown-ui/marked-ext';
5 +
import { createPublicClient, createWalletClient, custom, http } from "viem";
6 +
import { sepolia } from "viem/chains";
7 +
import { parseContractToMarkdown, type ContractResponse } from './utils/contractParser';
5 8
import '@markdown-ui/react/widgets.css';
6 -
import { parseContractToMarkdown, type ContractResponse } from './utils/contractParser';
9 +
7 10
8 11
const marked = new Marked().use(markedUiExtension);
9 12
14 17
  const [contractHtml, setContractHtml] = useState<string>("");
15 18
  const [loading, setLoading] = useState<boolean>(true);
16 19
  const [error, setError] = useState<string | null>(null);
20 +
  const [contractData, setContractData] = useState<ContractResponse | null>(null);
21 +
  const [counterValue, setCounterValue] = useState<bigint | undefined>();
22 +
23 +
  const publicClient = createPublicClient({
24 +
    chain: sepolia,
25 +
    transport: http(),
26 +
  });
27 +
28 +
29 +
  const walletClient = typeof window !== 'undefined' && (window as any).ethereum 
30 +
    ? createWalletClient({
31 +
        chain: sepolia,
32 +
        transport: custom((window as any).ethereum),
33 +
      })
34 +
    : null;
35 +
36 +
  const handleWidgetEvent = async (event: { detail: { id: string, value: unknown } }) => {
37 +
    if (!contractData || !walletClient) return;
38 +
39 +
    console.log('Widget event received:', event.detail);
40 +
41 +
    try {
42 +
      const { id, value } = event.detail;
43 +
44 +
      if (id === 'setNumber') {
45 +
        const newValue = BigInt((value as any)?.newValue || (value as string) || 0);
46 +
        console.log('Setting number to:', newValue);
47 +
48 +
        const [account] = await walletClient.requestAddresses();
49 +
50 +
        const { request } = await publicClient.simulateContract({
51 +
          address: CONTRACT_ADDRESS as `0x${string}`,
52 +
          abi: contractData.abi,
53 +
          functionName: 'setNumber',
54 +
          args: [newValue],
55 +
          account,
56 +
        });
57 +
58 +
        const hash = await walletClient.writeContract(request);
59 +
        console.log('Transaction hash:', hash);
60 +
        await readContractValue();
61 +
      } else if (id === 'increment') {
62 +
        console.log('Incrementing counter');
63 +
64 +
        const [account] = await walletClient.requestAddresses();
65 +
66 +
        const { request } = await publicClient.simulateContract({
67 +
          address: CONTRACT_ADDRESS as `0x${string}`,
68 +
          abi: contractData.abi,
69 +
          functionName: 'increment',
70 +
          args: [],
71 +
          account,
72 +
        });
73 +
74 +
        const hash = await walletClient.writeContract(request);
75 +
        console.log('Transaction hash:', hash);
76 +
        await readContractValue();
77 +
      }
78 +
    } catch (error) {
79 +
      console.error('Contract interaction error:', error);
80 +
    }
81 +
  };
82 +
83 +
  const readContractValue = async () => {
84 +
    if (!contractData) return;
85 +
86 +
    try {
87 +
      const result = await publicClient.readContract({
88 +
        address: CONTRACT_ADDRESS as `0x${string}`,
89 +
        abi: contractData.abi,
90 +
        functionName: 'number',
91 +
        args: [],
92 +
      });
93 +
      setCounterValue(result as unknown as bigint);
94 +
    } catch (error) {
95 +
      console.error('Contract read error:', error);
96 +
    }
97 +
  };
17 98
18 99
  useEffect(() => {
19 100
    const fetchContractData = async () => {
20 101
      try {
21 102
        setLoading(true);
22 103
        const response = await fetch(
23 -
          `https://sourcify.dev/server/v2/contract/${CHAIN_ID}/${CONTRACT_ADDRESS}?fields=devdoc`
104 +
          `https://sourcify.dev/server/v2/contract/${CHAIN_ID}/${CONTRACT_ADDRESS}?fields=devdoc,abi`
24 105
        );
25 -
        
106 +
26 107
        if (!response.ok) {
27 108
          throw new Error(`HTTP error! status: ${response.status}`);
28 109
        }
29 -
        
110 +
30 111
        const data: ContractResponse = await response.json();
31 -
        
112 +
        setContractData(data);
113 +
32 114
        const markdownContent = parseContractToMarkdown(data);
33 -
        
115 +
34 116
        console.log(markdownContent);
35 117
        const html = await marked.parse(markdownContent || '# No markdown widgets found');
36 118
        setContractHtml(html);
45 127
    fetchContractData();
46 128
  }, []);
47 129
130 +
  useEffect(() => {
131 +
    if (contractData) {
132 +
      readContractValue();
133 +
    }
134 +
  }, [contractData]);
135 +
136 +
  useEffect(() => {
137 +
    if (!contractHtml) return;
138 +
139 +
    // Set up click handlers for widget buttons
140 +
    const timer = setTimeout(() => {
141 +
      const buttons = document.querySelectorAll('button');
142 +
      
143 +
      buttons.forEach(button => {
144 +
        const container = button.closest('.widget-container');
145 +
        if (container) {
146 +
          const widget = container.querySelector('markdown-ui-widget');
147 +
          const widgetId = widget?.getAttribute('id');
148 +
          
149 +
          if (widgetId === 'increment' || widgetId === 'setNumber') {
150 +
            button.addEventListener('click', async (e) => {
151 +
              e.preventDefault();
152 +
              
153 +
              if (widgetId === 'increment') {
154 +
                await handleWidgetEvent({ detail: { id: 'increment', value: null } });
155 +
              } else if (widgetId === 'setNumber') {
156 +
                const input = container.querySelector('input') as HTMLInputElement;
157 +
                const value = input ? input.value : '42';
158 +
                await handleWidgetEvent({ detail: { id: 'setNumber', value: { newValue: value } } });
159 +
              }
160 +
            });
161 +
          }
162 +
        }
163 +
      });
164 +
    }, 1000);
165 +
166 +
    return () => {
167 +
      clearTimeout(timer);
168 +
    };
169 +
  }, [contractHtml, contractData, walletClient]);
170 +
171 +
48 172
  if (loading) {
49 173
    return (
50 174
      <div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
63 187
64 188
  return (
65 189
    <div className="max-w-xl mx-auto flex flex-col gap-6 items-center justify-center min-h-screen">
190 +
      {counterValue !== undefined && (
191 +
        <div className="text-center mb-4">
192 +
          <p className="text-lg font-semibold">Current Counter: {counterValue.toString()}</p>
193 +
        </div>
194 +
      )}
66 195
      <MarkdownUI html={contractHtml} />
67 196
    </div>
68 197
  );
client/src/utils/contractParser.ts +1 −0
14 14
15 15
interface ContractResponse {
16 16
  devdoc: DevDoc;
17 +
  abi: any[];
17 18
  matchId?: string;
18 19
  creationMatch?: string;
19 20
  runtimeMatch?: string;
client/src/vite-env.d.ts (deleted) +0 −1
1 -
/// <reference types="vite/client" />
client/vite.config.ts +1 −9
1 1
import { defineConfig } from 'vite'
2 2
import react from '@vitejs/plugin-react'
3 3
import tailwindcss from '@tailwindcss/vite'
4 -
import path from 'path'
5 4
6 5
export default defineConfig({
7 -
  plugins: [react(), tailwindcss()],
8 -
  resolve: {
9 -
    alias: {
10 -
      "@client": path.resolve(__dirname, "./src"),
11 -
      "@server": path.resolve(__dirname, "../server/src"),
12 -
      "@shared": path.resolve(__dirname, "../shared/src")
13 -
    }
14 -
  }
6 +
  plugins: [react(), tailwindcss()]
15 7
})