From 3ae7cf6cf95ad1c41c8d6c9e51d641a5055bdaec Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 13:36:26 +0000 Subject: [PATCH 1/5] [#35] Scaffold @plotlink/sdk with core methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds packages/sdk/ with TypeScript + tsup (ESM + CJS dual output): - PlotLink client class with constructor({ privateKey, rpcUrl }) - createStoryline() — upload to IPFS via Filebase, call StoryFactory - chainPlot() — upload content, call StoryFactory.chainPlot() - getStoryline() / getPlots() — read from on-chain event logs - registerAgent() — call ERC-8004 register(agentURI) - claimRoyalties(tokenAddress) — call MCV2_Bond.claimRoyalties() - getRoyaltyInfo(tokenAddress) — read unclaimed royalty balance ABIs mirrored from lib/contracts/ for standalone use. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +- packages/sdk/package.json | 42 +++ packages/sdk/src/abi.ts | 156 ++++++++++++ packages/sdk/src/client.ts | 466 ++++++++++++++++++++++++++++++++++ packages/sdk/src/constants.ts | 32 +++ packages/sdk/src/index.ts | 41 +++ packages/sdk/src/ipfs.ts | 88 +++++++ packages/sdk/tsconfig.json | 20 ++ packages/sdk/tsup.config.ts | 11 + 9 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/abi.ts create mode 100644 packages/sdk/src/client.ts create mode 100644 packages/sdk/src/constants.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/ipfs.ts create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsup.config.ts diff --git a/.gitignore b/.gitignore index 7b8da95f..35d10faa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +node_modules /.pnp .pnp.* .yarn/* @@ -19,6 +19,7 @@ # production /build +dist # misc .DS_Store diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..63f68438 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,42 @@ +{ + "name": "@plotlink/sdk", + "version": "0.1.0", + "description": "TypeScript SDK for the PlotLink protocol — create storylines, chain plots, register agents, and claim royalties on Base.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "lint": "eslint src/" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1009.0", + "viem": "^2.47.2" + }, + "devDependencies": { + "eslint": "^9", + "tsup": "^8.4.0", + "typescript": "^5" + }, + "peerDependencies": { + "viem": "^2.0.0" + }, + "license": "MIT" +} diff --git a/packages/sdk/src/abi.ts b/packages/sdk/src/abi.ts new file mode 100644 index 00000000..17b06ece --- /dev/null +++ b/packages/sdk/src/abi.ts @@ -0,0 +1,156 @@ +/** + * Contract ABIs for the PlotLink protocol. + * + * Mirrored from lib/contracts/abi.ts and lib/contracts/erc8004.ts in the web + * app. The SDK keeps its own copy to avoid cross-package imports. + */ + +// --------------------------------------------------------------------------- +// StoryFactory +// --------------------------------------------------------------------------- + +export const storyFactoryAbi = [ + // Events + { + type: "event", + name: "PlotChained", + inputs: [ + { name: "storylineId", type: "uint256", indexed: true }, + { name: "plotIndex", type: "uint256", indexed: true }, + { name: "writer", type: "address", indexed: true }, + { name: "contentCID", type: "string", indexed: false }, + { name: "contentHash", type: "bytes32", indexed: false }, + ], + }, + { + type: "event", + name: "StorylineCreated", + inputs: [ + { name: "storylineId", type: "uint256", indexed: true }, + { name: "writer", type: "address", indexed: true }, + { name: "tokenAddress", type: "address", indexed: false }, + { name: "title", type: "string", indexed: false }, + { name: "hasDeadline", type: "bool", indexed: false }, + { name: "openingCID", type: "string", indexed: false }, + { name: "openingHash", type: "bytes32", indexed: false }, + ], + }, + { + type: "event", + name: "Donation", + inputs: [ + { name: "storylineId", type: "uint256", indexed: true }, + { name: "donor", type: "address", indexed: true }, + { name: "amount", type: "uint256", indexed: false }, + ], + }, + // Functions + { + type: "function", + name: "createStoryline", + stateMutability: "nonpayable", + inputs: [ + { name: "title", type: "string" }, + { name: "openingCID", type: "string" }, + { name: "openingHash", type: "bytes32" }, + { name: "hasDeadline", type: "bool" }, + ], + outputs: [{ name: "storylineId", type: "uint256" }], + }, + { + type: "function", + name: "chainPlot", + stateMutability: "nonpayable", + inputs: [ + { name: "storylineId", type: "uint256" }, + { name: "contentCID", type: "string" }, + { name: "contentHash", type: "bytes32" }, + ], + outputs: [], + }, + { + type: "function", + name: "donate", + stateMutability: "nonpayable", + inputs: [ + { name: "storylineId", type: "uint256" }, + { name: "amount", type: "uint256" }, + ], + outputs: [], + }, +] as const; + +// --------------------------------------------------------------------------- +// ERC-8004 Agent Registry +// --------------------------------------------------------------------------- + +export const erc8004Abi = [ + { + type: "function", + name: "agentIdByWallet", + stateMutability: "view", + inputs: [{ name: "wallet", type: "address" }], + outputs: [{ name: "agentId", type: "uint256" }], + }, + { + type: "function", + name: "register", + stateMutability: "nonpayable", + inputs: [{ name: "agentURI", type: "string" }], + outputs: [{ name: "agentId", type: "uint256" }], + }, + { + type: "event", + name: "Registered", + inputs: [ + { name: "agentId", type: "uint256", indexed: true }, + { name: "agentURI", type: "string", indexed: false }, + { name: "owner", type: "address", indexed: true }, + ], + }, +] as const; + +// --------------------------------------------------------------------------- +// MCV2_Bond (Mint Club V2 bonding curve) +// --------------------------------------------------------------------------- + +export const mcv2BondAbi = [ + { + type: "function", + name: "getRoyaltyInfo", + stateMutability: "view", + inputs: [{ name: "token", type: "address" }], + outputs: [ + { name: "royalty", type: "uint256" }, + { name: "royaltyBeneficiary", type: "address" }, + ], + }, + { + type: "function", + name: "claimRoyalties", + stateMutability: "nonpayable", + inputs: [{ name: "token", type: "address" }], + outputs: [], + }, + { + type: "function", + name: "priceForNextMint", + stateMutability: "view", + inputs: [{ name: "token", type: "address" }], + outputs: [{ name: "", type: "uint128" }], + }, + { + type: "function", + name: "tokenBond", + stateMutability: "view", + inputs: [{ name: "token", type: "address" }], + outputs: [ + { name: "creator", type: "address" }, + { name: "mintRoyalty", type: "uint16" }, + { name: "burnRoyalty", type: "uint16" }, + { name: "createdAt", type: "uint40" }, + { name: "reserveToken", type: "address" }, + { name: "reserveBalance", type: "uint256" }, + ], + }, +] as const; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 00000000..728039f0 --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,466 @@ +import { + createPublicClient, + createWalletClient, + http, + keccak256, + toHex, + decodeEventLog, + type PublicClient, + type WalletClient, + type Address, + type Hex, + type TransportConfig, + type Chain, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base, baseSepolia } from "viem/chains"; + +import { storyFactoryAbi, erc8004Abi, mcv2BondAbi } from "./abi"; +import { + STORY_FACTORY_ADDRESS, + MCV2_BOND_ADDRESS, + ERC8004_REGISTRY_ADDRESS, + BASE_SEPOLIA_CHAIN_ID, +} from "./constants"; +import { uploadWithRetry, type FilebaseConfig } from "./ipfs"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Configuration for the PlotLink SDK client. + */ +export interface PlotLinkConfig { + /** Hex-encoded private key (with or without 0x prefix). */ + privateKey: string; + /** JSON-RPC URL for the Base chain. */ + rpcUrl: string; + /** Chain ID — defaults to 84532 (Base Sepolia). */ + chainId?: number; + /** Override StoryFactory contract address. */ + storyFactoryAddress?: Address; + /** Override MCV2_Bond contract address. */ + mcv2BondAddress?: Address; + /** Override ERC-8004 Registry contract address. */ + erc8004RegistryAddress?: Address; + /** + * Filebase credentials for IPFS uploads. + * Required for createStoryline() and chainPlot(). + * If omitted, those methods will throw when called. + */ + filebase?: FilebaseConfig; +} + +export interface CreateStorylineResult { + storylineId: bigint; + txHash: Hex; + contentCid: string; +} + +export interface ChainPlotResult { + txHash: Hex; + contentCid: string; +} + +export interface StorylineInfo { + creator: Address; + tokenAddress: Address; + title: string; + hasDeadline: boolean; + openingCID: string; + openingHash: Hex; +} + +export interface PlotInfo { + storylineId: bigint; + plotIndex: bigint; + writer: Address; + contentCID: string; + contentHash: Hex; +} + +export interface RegisterAgentResult { + agentId: bigint; + txHash: Hex; +} + +export interface RoyaltyInfo { + unclaimed: bigint; + beneficiary: Address; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * PlotLink SDK client for interacting with the PlotLink protocol on Base. + * + * Provides methods for storyline management, plot chaining, agent registration, + * and royalty claims. Uses viem for contract interactions and Filebase for + * IPFS content uploads. + * + * @example + * ```ts + * const client = new PlotLink({ + * privateKey: "0x...", + * rpcUrl: "https://sepolia.base.org", + * filebase: { accessKey: "...", secretKey: "...", bucket: "my-bucket" }, + * }); + * + * const { storylineId } = await client.createStoryline( + * "My Story", + * "Once upon a time...", + * "Fantasy", + * ); + * ``` + */ +export class PlotLink { + readonly publicClient: PublicClient; + readonly walletClient: WalletClient; + readonly address: Address; + + private readonly storyFactory: Address; + private readonly mcv2Bond: Address; + private readonly erc8004Registry: Address; + private readonly filebase: FilebaseConfig | undefined; + private readonly chain: Chain; + + constructor(config: PlotLinkConfig) { + const chainId = config.chainId ?? BASE_SEPOLIA_CHAIN_ID; + this.chain = chainId === 8453 ? base : baseSepolia; + + const normalizedKey = config.privateKey.startsWith("0x") + ? config.privateKey + : `0x${config.privateKey}`; + const account = privateKeyToAccount(normalizedKey as Hex); + + this.publicClient = createPublicClient({ + chain: this.chain, + transport: http(config.rpcUrl), + }); + + this.walletClient = createWalletClient({ + account, + chain: this.chain, + transport: http(config.rpcUrl), + }); + + this.address = account.address; + this.storyFactory = + config.storyFactoryAddress ?? STORY_FACTORY_ADDRESS; + this.mcv2Bond = config.mcv2BondAddress ?? MCV2_BOND_ADDRESS; + this.erc8004Registry = + config.erc8004RegistryAddress ?? ERC8004_REGISTRY_ADDRESS; + this.filebase = config.filebase; + } + + // ------------------------------------------------------------------------- + // Storyline methods + // ------------------------------------------------------------------------- + + /** + * Create a new storyline. + * + * Uploads the opening content to IPFS via Filebase, computes its keccak256 + * hash, and calls StoryFactory.createStoryline() on-chain. + * + * @param title - Storyline title + * @param content - Opening plot content (plain text) + * @param genre - Genre label (stored off-chain; used for agent URI composition) + * @param hasDeadline - Whether the storyline has a sunset deadline (default: false) + * @returns The storyline ID, transaction hash, and IPFS CID + */ + async createStoryline( + title: string, + content: string, + genre: string, + hasDeadline = false, + ): Promise { + this.requireFilebase(); + + const key = `plotlink/storylines/${Date.now()}-${slugify(title)}.txt`; + const contentCid = await uploadWithRetry(content, key, this.filebase!); + const contentHash = hashContent(content); + + const { request } = await this.publicClient.simulateContract({ + account: this.walletClient.account!, + address: this.storyFactory, + abi: storyFactoryAbi, + functionName: "createStoryline", + args: [title, contentCid, contentHash, hasDeadline], + }); + + const txHash = await this.walletClient.writeContract(request); + const receipt = await this.publicClient.waitForTransactionReceipt({ + hash: txHash, + }); + + // Decode StorylineCreated event to get the storylineId + let storylineId = BigInt(0); + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: storyFactoryAbi, + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === "StorylineCreated") { + storylineId = (decoded.args as { storylineId: bigint }).storylineId; + break; + } + } catch { + // Skip logs from other contracts + } + } + + return { storylineId, txHash, contentCid }; + } + + /** + * Chain a new plot onto an existing storyline. + * + * Uploads content to IPFS and calls StoryFactory.chainPlot() on-chain. + * + * @param storylineId - The storyline to chain onto + * @param content - Plot content (plain text) + * @returns Transaction hash and IPFS CID + */ + async chainPlot( + storylineId: bigint, + content: string, + ): Promise { + this.requireFilebase(); + + const key = `plotlink/plots/${storylineId}-${Date.now()}.txt`; + const contentCid = await uploadWithRetry(content, key, this.filebase!); + const contentHash = hashContent(content); + + const { request } = await this.publicClient.simulateContract({ + account: this.walletClient.account!, + address: this.storyFactory, + abi: storyFactoryAbi, + functionName: "chainPlot", + args: [storylineId, contentCid, contentHash], + }); + + const txHash = await this.walletClient.writeContract(request); + await this.publicClient.waitForTransactionReceipt({ hash: txHash }); + + return { txHash, contentCid }; + } + + /** + * Read storyline data from the StorylineCreated event logs. + * + * Fetches the creation event for the given storyline ID to retrieve + * on-chain metadata (title, token address, opening CID, etc.). + * + * @param storylineId - The storyline ID to look up + * @returns Storyline info or null if not found + */ + async getStoryline(storylineId: bigint): Promise { + const logs = await this.publicClient.getLogs({ + address: this.storyFactory, + event: storyFactoryAbi[1], // StorylineCreated event + args: { storylineId }, + fromBlock: BigInt(0), + toBlock: "latest", + }); + + if (logs.length === 0) return null; + + const log = logs[0]; + const args = log.args as { + writer: Address; + tokenAddress: Address; + title: string; + hasDeadline: boolean; + openingCID: string; + openingHash: Hex; + }; + + return { + creator: args.writer, + tokenAddress: args.tokenAddress, + title: args.title, + hasDeadline: args.hasDeadline, + openingCID: args.openingCID, + openingHash: args.openingHash, + }; + } + + /** + * Read all plots for a storyline from PlotChained event logs. + * + * @param storylineId - The storyline ID to query + * @returns Array of plot info objects, ordered by plot index + */ + async getPlots(storylineId: bigint): Promise { + const logs = await this.publicClient.getLogs({ + address: this.storyFactory, + event: storyFactoryAbi[0], // PlotChained event + args: { storylineId }, + fromBlock: BigInt(0), + toBlock: "latest", + }); + + return logs.map((log) => { + const args = log.args as { + storylineId: bigint; + plotIndex: bigint; + writer: Address; + contentCID: string; + contentHash: Hex; + }; + return { + storylineId: args.storylineId, + plotIndex: args.plotIndex, + writer: args.writer, + contentCID: args.contentCID, + contentHash: args.contentHash, + }; + }); + } + + // ------------------------------------------------------------------------- + // Agent methods + // ------------------------------------------------------------------------- + + /** + * Register an AI agent on the ERC-8004 Agent Identity Registry. + * + * Constructs a JSON agent URI from the provided metadata and calls + * `register(agentURI)` on the ERC-8004 registry contract. + * + * @param name - Agent display name + * @param description - Short description of the agent + * @param genre - Primary genre the agent writes in + * @param model - LLM model identifier (e.g. "Claude Opus 4") + * @returns Agent ID and transaction hash + */ + async registerAgent( + name: string, + description: string, + genre: string, + model: string, + ): Promise { + const agentURI = JSON.stringify({ name, description, genre, model }); + + const { request } = await this.publicClient.simulateContract({ + account: this.walletClient.account!, + address: this.erc8004Registry, + abi: erc8004Abi, + functionName: "register", + args: [agentURI], + }); + + const txHash = await this.walletClient.writeContract(request); + const receipt = await this.publicClient.waitForTransactionReceipt({ + hash: txHash, + }); + + // Decode Registered event to get the agentId + let agentId = BigInt(0); + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: erc8004Abi, + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === "Registered") { + agentId = (decoded.args as { agentId: bigint }).agentId; + break; + } + } catch { + // Skip logs from other contracts + } + } + + return { agentId, txHash }; + } + + // ------------------------------------------------------------------------- + // Royalty methods + // ------------------------------------------------------------------------- + + /** + * Get unclaimed royalty info for a storyline token. + * + * @param tokenAddress - The storyline's ERC-20 token address + * @returns Unclaimed royalty amount and beneficiary address + */ + async getRoyaltyInfo(tokenAddress: Address): Promise { + const result = await this.publicClient.readContract({ + address: this.mcv2Bond, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [tokenAddress], + }); + + return { + unclaimed: (result as [bigint, Address])[0], + beneficiary: (result as [bigint, Address])[1], + }; + } + + /** + * Claim accumulated royalties for a storyline token from the MCV2_Bond + * bonding curve contract. + * + * @param tokenAddress - The storyline's ERC-20 token address + * @returns Transaction hash + */ + async claimRoyalties(tokenAddress: Address): Promise { + const { request } = await this.publicClient.simulateContract({ + account: this.walletClient.account!, + address: this.mcv2Bond, + abi: mcv2BondAbi, + functionName: "claimRoyalties", + args: [tokenAddress], + }); + + const txHash = await this.walletClient.writeContract(request); + await this.publicClient.waitForTransactionReceipt({ hash: txHash }); + + return txHash; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private requireFilebase(): void { + if (!this.filebase) { + throw new Error( + "Filebase config required for IPFS uploads. " + + "Pass { filebase: { accessKey, secretKey, bucket } } to the PlotLink constructor.", + ); + } + } +} + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + +/** + * Compute keccak256 hash of content, matching the on-chain contentHash. + * Same encoding as the web app's hashContent (lib/content.ts). + */ +function hashContent(content: string): Hex { + return keccak256(toHex(content)); +} + +/** + * Simple slugify for S3 keys. + */ +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40); +} diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts new file mode 100644 index 00000000..82c5cb95 --- /dev/null +++ b/packages/sdk/src/constants.ts @@ -0,0 +1,32 @@ +/** + * Default contract addresses and chain configuration for PlotLink on Base. + * + * Mirrored from lib/contracts/constants.ts in the web app. + * These serve as defaults — callers can override via PlotLinkConfig. + */ + +// --------------------------------------------------------------------------- +// Chain +// --------------------------------------------------------------------------- + +/** Base Sepolia (testnet) chain ID. */ +export const BASE_SEPOLIA_CHAIN_ID = 84532; + +/** Base (mainnet) chain ID. */ +export const BASE_MAINNET_CHAIN_ID = 8453; + +// --------------------------------------------------------------------------- +// PlotLink contracts (Base Sepolia defaults) +// --------------------------------------------------------------------------- + +/** StoryFactory — storyline + plot management. */ +export const STORY_FACTORY_ADDRESS = + "0x05C4d59529807316D6fA09cdaA509adDfe85b474" as const; + +/** MCV2_Bond — bonding curve trading, token creation, royalty distribution. */ +export const MCV2_BOND_ADDRESS = + "0x5dfA75b0185efBaEF286E80B847ce84ff8a62C2d" as const; + +/** ERC-8004 Agent Identity Registry. */ +export const ERC8004_REGISTRY_ADDRESS = + "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..4f3b2ef3 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,41 @@ +/** + * @plotlink/sdk — TypeScript SDK for the PlotLink protocol on Base. + * + * @example + * ```ts + * import { PlotLink } from "@plotlink/sdk"; + * + * const client = new PlotLink({ + * privateKey: "0x...", + * rpcUrl: "https://sepolia.base.org", + * filebase: { accessKey: "...", secretKey: "...", bucket: "my-bucket" }, + * }); + * + * const { storylineId } = await client.createStoryline( + * "My Story", + * "Once upon a time...", + * "Fantasy", + * ); + * ``` + */ + +export { PlotLink } from "./client"; +export type { + PlotLinkConfig, + CreateStorylineResult, + ChainPlotResult, + StorylineInfo, + PlotInfo, + RegisterAgentResult, + RoyaltyInfo, +} from "./client"; +export type { FilebaseConfig } from "./ipfs"; + +// Re-export constants for callers who need contract addresses +export { + STORY_FACTORY_ADDRESS, + MCV2_BOND_ADDRESS, + ERC8004_REGISTRY_ADDRESS, + BASE_SEPOLIA_CHAIN_ID, + BASE_MAINNET_CHAIN_ID, +} from "./constants"; diff --git a/packages/sdk/src/ipfs.ts b/packages/sdk/src/ipfs.ts new file mode 100644 index 00000000..c91edbf1 --- /dev/null +++ b/packages/sdk/src/ipfs.ts @@ -0,0 +1,88 @@ +import { + S3Client, + PutObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; + +/** + * Filebase configuration for IPFS pinning via S3-compatible API. + */ +export interface FilebaseConfig { + accessKey: string; + secretKey: string; + bucket: string; +} + +/** + * Create an S3-compatible client for Filebase IPFS pinning. + */ +function createFilebaseClient(config: FilebaseConfig): S3Client { + return new S3Client({ + endpoint: "https://s3.filebase.com", + region: "us-east-1", + credentials: { + accessKeyId: config.accessKey, + secretAccessKey: config.secretKey, + }, + }); +} + +/** + * Upload content to Filebase (IPFS pinning via S3 API) and return the CID. + * + * The CID is retrieved from the HeadObject response metadata after upload. + * Content is stored as UTF-8 plain text. + */ +export async function uploadToIPFS( + content: string, + key: string, + config: FilebaseConfig, +): Promise { + const s3 = createFilebaseClient(config); + + await s3.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: content, + ContentType: "text/plain; charset=utf-8", + }), + ); + + const head = await s3.send( + new HeadObjectCommand({ Bucket: config.bucket, Key: key }), + ); + const cid = head.Metadata?.cid; + if (!cid) { + throw new Error("Filebase response missing CID in metadata"); + } + + return cid; +} + +/** + * Upload content to IPFS with retry logic. + * + * 3 attempts with exponential backoff (1s, 2s, 4s). + */ +export async function uploadWithRetry( + content: string, + key: string, + config: FilebaseConfig, + maxRetries = 3, +): Promise { + let lastError: Error | null = null; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await uploadToIPFS(content, key, config); + } catch (error) { + lastError = error instanceof Error ? error : new Error("Unknown error"); + if (attempt < maxRetries) { + await new Promise((r) => + setTimeout(r, Math.pow(2, attempt - 1) * 1000), + ); + } + } + } + throw lastError || new Error("IPFS upload failed after retries"); +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..1358d953 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts new file mode 100644 index 00000000..f8613370 --- /dev/null +++ b/packages/sdk/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + outDir: "dist", +}); From 551d2f6ce5577ff5d857168e9e83b18f453f3ec0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 13:41:31 +0000 Subject: [PATCH 2/5] [#35] Add setAgentWallet method and ABI to SDK Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/abi.ts | 12 +++++++ packages/sdk/src/client.ts | 73 ++++++++++++++++++++++++++++++++++++++ packages/sdk/src/index.ts | 1 + 3 files changed, 86 insertions(+) diff --git a/packages/sdk/src/abi.ts b/packages/sdk/src/abi.ts index 17b06ece..f4b1b173 100644 --- a/packages/sdk/src/abi.ts +++ b/packages/sdk/src/abi.ts @@ -99,6 +99,18 @@ export const erc8004Abi = [ inputs: [{ name: "agentURI", type: "string" }], outputs: [{ name: "agentId", type: "uint256" }], }, + { + type: "function", + name: "setAgentWallet", + stateMutability: "nonpayable", + inputs: [ + { name: "agentId", type: "uint256" }, + { name: "newWallet", type: "address" }, + { name: "deadline", type: "uint256" }, + { name: "signature", type: "bytes" }, + ], + outputs: [], + }, { type: "event", name: "Registered", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 728039f0..c5890b0f 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -85,6 +85,10 @@ export interface RegisterAgentResult { txHash: Hex; } +export interface SetAgentWalletResult { + txHash: Hex; +} + export interface RoyaltyInfo { unclaimed: bigint; beneficiary: Address; @@ -382,6 +386,75 @@ export class PlotLink { return { agentId, txHash }; } + /** + * Set (or rotate) the wallet for a registered agent. + * + * Builds an EIP-712 `AgentWalletSet` signature using the agent wallet's + * private key and submits the `setAgentWallet` transaction from the SDK's + * configured (owner) wallet. + * + * @param agentId - The on-chain agent ID (from registerAgent) + * @param newWallet - The new wallet address to assign + * @param agentWalletPrivateKey - Hex-encoded private key of the agent wallet (signer) + * @returns Transaction hash + */ + async setAgentWallet( + agentId: bigint, + newWallet: Address, + agentWalletPrivateKey: string, + ): Promise { + const normalizedKey = agentWalletPrivateKey.startsWith("0x") + ? agentWalletPrivateKey + : `0x${agentWalletPrivateKey}`; + const agentAccount = privateKeyToAccount(normalizedKey as Hex); + + // Deadline: 1 hour from now + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const domain = { + name: "ERC8004IdentityRegistry", + version: "1", + chainId: this.chain.id, + verifyingContract: this.erc8004Registry, + } as const; + + const types = { + AgentWalletSet: [ + { name: "agentId", type: "uint256" }, + { name: "newWallet", type: "address" }, + { name: "owner", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + } as const; + + const message = { + agentId, + newWallet, + owner: this.address, + deadline, + } as const; + + const signature = await agentAccount.signTypedData({ + domain, + types, + primaryType: "AgentWalletSet", + message, + }); + + const { request } = await this.publicClient.simulateContract({ + account: this.walletClient.account!, + address: this.erc8004Registry, + abi: erc8004Abi, + functionName: "setAgentWallet", + args: [agentId, newWallet, deadline, signature], + }); + + const txHash = await this.walletClient.writeContract(request); + await this.publicClient.waitForTransactionReceipt({ hash: txHash }); + + return { txHash }; + } + // ------------------------------------------------------------------------- // Royalty methods // ------------------------------------------------------------------------- diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 4f3b2ef3..53fa7dfe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -27,6 +27,7 @@ export type { StorylineInfo, PlotInfo, RegisterAgentResult, + SetAgentWalletResult, RoyaltyInfo, } from "./client"; export type { FilebaseConfig } from "./ipfs"; From 19fb2479b2f224e9a8e122152d5d39b515b42957 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 13:44:39 +0000 Subject: [PATCH 3/5] [#35] Fix fromBlock, viem dep, and chainId fallback - Replace fromBlock: BigInt(0) with DEPLOYMENT_BLOCK constant to avoid full-chain scans that time out on Base - Remove viem from dependencies, keep only as peerDependency - Throw on unsupported chainId instead of silently falling back to Base Sepolia Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/package.json | 3 +-- packages/sdk/src/client.ts | 11 +++++++++-- packages/sdk/src/constants.ts | 10 ++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 63f68438..c8f8833e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -27,8 +27,7 @@ "lint": "eslint src/" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1009.0", - "viem": "^2.47.2" + "@aws-sdk/client-s3": "^3.1009.0" }, "devDependencies": { "eslint": "^9", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c5890b0f..3a800257 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -21,6 +21,8 @@ import { MCV2_BOND_ADDRESS, ERC8004_REGISTRY_ADDRESS, BASE_SEPOLIA_CHAIN_ID, + DEPLOYMENT_BLOCK, + SUPPORTED_CHAIN_IDS, } from "./constants"; import { uploadWithRetry, type FilebaseConfig } from "./ipfs"; @@ -133,6 +135,11 @@ export class PlotLink { constructor(config: PlotLinkConfig) { const chainId = config.chainId ?? BASE_SEPOLIA_CHAIN_ID; + if (!SUPPORTED_CHAIN_IDS.has(chainId)) { + throw new Error( + `Unsupported chainId: ${chainId}. PlotLink SDK supports Base (8453) and Base Sepolia (84532).`, + ); + } this.chain = chainId === 8453 ? base : baseSepolia; const normalizedKey = config.privateKey.startsWith("0x") @@ -269,7 +276,7 @@ export class PlotLink { address: this.storyFactory, event: storyFactoryAbi[1], // StorylineCreated event args: { storylineId }, - fromBlock: BigInt(0), + fromBlock: DEPLOYMENT_BLOCK, toBlock: "latest", }); @@ -306,7 +313,7 @@ export class PlotLink { address: this.storyFactory, event: storyFactoryAbi[0], // PlotChained event args: { storylineId }, - fromBlock: BigInt(0), + fromBlock: DEPLOYMENT_BLOCK, toBlock: "latest", }); diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 82c5cb95..1af71604 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -15,6 +15,16 @@ export const BASE_SEPOLIA_CHAIN_ID = 84532; /** Base (mainnet) chain ID. */ export const BASE_MAINNET_CHAIN_ID = 8453; +/** + * Approximate deployment block for PlotLink contracts on Base Sepolia. + * Used as the default fromBlock in event log queries to avoid scanning + * from genesis (which times out on Base). + */ +export const DEPLOYMENT_BLOCK = BigInt(20_000_000); + +/** Supported chain IDs for the PlotLink SDK. */ +export const SUPPORTED_CHAIN_IDS = new Set([BASE_SEPOLIA_CHAIN_ID, BASE_MAINNET_CHAIN_ID]); + // --------------------------------------------------------------------------- // PlotLink contracts (Base Sepolia defaults) // --------------------------------------------------------------------------- From 4d12c4927b13977c3c23b06861147d9419b47155 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 13:47:06 +0000 Subject: [PATCH 4/5] [#35] Exclude SDK package from root typecheck Co-Authored-By: Claude Opus 4.6 (1M context) --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index cf9c65d3..a9ac38f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "packages"] } From 4eb57e060d4b2a22243cd9735755b6b8db7a0099 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 13:53:28 +0000 Subject: [PATCH 5/5] [#35] Fix genre usage, validation, ABI indexing, unused import - Include genre in IPFS metadata payload (was accepted but discarded) - Add validateNonEmpty() for required string params at SDK boundary - Replace fragile storyFactoryAbi[0]/[1] with named find() constants - Remove unused TransportConfig import Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 3a800257..ab9acbd2 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -9,13 +9,20 @@ import { type WalletClient, type Address, type Hex, - type TransportConfig, type Chain, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, baseSepolia } from "viem/chains"; import { storyFactoryAbi, erc8004Abi, mcv2BondAbi } from "./abi"; + +// Named ABI event references (avoid fragile array indexing) +const StorylineCreatedEvent = storyFactoryAbi.find( + (item) => item.type === "event" && item.name === "StorylineCreated", +)!; +const PlotChainedEvent = storyFactoryAbi.find( + (item) => item.type === "event" && item.name === "PlotChained", +)!; import { STORY_FACTORY_ADDRESS, MCV2_BOND_ADDRESS, @@ -190,9 +197,13 @@ export class PlotLink { hasDeadline = false, ): Promise { this.requireFilebase(); + validateNonEmpty("title", title); + validateNonEmpty("content", content); + validateNonEmpty("genre", genre); - const key = `plotlink/storylines/${Date.now()}-${slugify(title)}.txt`; - const contentCid = await uploadWithRetry(content, key, this.filebase!); + const metadata = JSON.stringify({ title, genre, content }); + const key = `plotlink/storylines/${Date.now()}-${slugify(title)}.json`; + const contentCid = await uploadWithRetry(metadata, key, this.filebase!); const contentHash = hashContent(content); const { request } = await this.publicClient.simulateContract({ @@ -243,6 +254,7 @@ export class PlotLink { content: string, ): Promise { this.requireFilebase(); + validateNonEmpty("content", content); const key = `plotlink/plots/${storylineId}-${Date.now()}.txt`; const contentCid = await uploadWithRetry(content, key, this.filebase!); @@ -274,7 +286,7 @@ export class PlotLink { async getStoryline(storylineId: bigint): Promise { const logs = await this.publicClient.getLogs({ address: this.storyFactory, - event: storyFactoryAbi[1], // StorylineCreated event + event: StorylineCreatedEvent, args: { storylineId }, fromBlock: DEPLOYMENT_BLOCK, toBlock: "latest", @@ -311,7 +323,7 @@ export class PlotLink { async getPlots(storylineId: bigint): Promise { const logs = await this.publicClient.getLogs({ address: this.storyFactory, - event: storyFactoryAbi[0], // PlotChained event + event: PlotChainedEvent, args: { storylineId }, fromBlock: DEPLOYMENT_BLOCK, toBlock: "latest", @@ -357,6 +369,11 @@ export class PlotLink { genre: string, model: string, ): Promise { + validateNonEmpty("name", name); + validateNonEmpty("description", description); + validateNonEmpty("genre", genre); + validateNonEmpty("model", model); + const agentURI = JSON.stringify({ name, description, genre, model }); const { request } = await this.publicClient.simulateContract({ @@ -526,6 +543,15 @@ export class PlotLink { // Utility functions // --------------------------------------------------------------------------- +/** + * Validate that a required string parameter is non-empty. + */ +function validateNonEmpty(name: string, value: string): void { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`"${name}" must be a non-empty string.`); + } +} + /** * Compute keccak256 hash of content, matching the on-chain contentHash. * Same encoding as the web app's hashContent (lib/content.ts).