diff --git a/src/ACTPClient.ts b/src/ACTPClient.ts index 0e7b91c..3090411 100644 --- a/src/ACTPClient.ts +++ b/src/ACTPClient.ts @@ -63,6 +63,7 @@ import { SmartWalletCall } from './wallet/aa/constants'; import { SmartWalletRouter, createSmartWalletRouter } from './wallet/SmartWalletRouter'; import { buildActivationBatch, ActivationScenario } from './wallet/aa/TransactionBatcher'; import { loadPendingPublish, deletePendingPublish, PendingPublish } from './config/pendingPublish'; +import { loadBuyerLink, BuyerLink } from './config/buyerLink'; import { sdkLogger } from './utils/Logger'; import { SettleOnInteract } from './settle/SettleOnInteract'; @@ -927,6 +928,19 @@ export class ACTPClient { // Ignore file read errors } + // Load buyer-link marker (may be null). A pure buyer (intent: pay) + // links instead of registering, so it has no on-chain configHash + // and no pending-publish — this marker is the signal that lets the + // gate below grant the gas-sponsored auto wallet anyway (AIP-18 + // DEC-8). It triggers NO lazy on-chain activation (lazyPending + // stays null, so the activation path is never entered). + let buyerLink: BuyerLink | null = null; + try { + buyerLink = loadBuyerLink(network); + } catch { + // Absence just means "not a linked buyer". + } + let useAutoWallet = false; if (registryAddr) { @@ -943,20 +957,28 @@ export class ACTPClient { lazyScenario = 'none'; } - // Gate: configHash != ZERO || hasPendingPublish → use AutoWallet + // Gate: configHash != ZERO || hasPendingPublish || linked buyer + // → use the gas-sponsored AutoWallet. + // The buyer leg (AIP-18 DEC-8) lets a pure buyer be gasless: it + // has no configHash and no pending-publish, but its only costly + // on-chain action — pay() — locks USDC in escrow, which is the + // anti-DOS backstop, so granting sponsorship here opens no + // free-gas vector. const hasOnChainConfig = onChainState.configHash !== ZERO_HASH; const hasPendingPublish = lazyPending !== null; + const isLinkedBuyer = buyerLink !== null; - if (hasOnChainConfig || hasPendingPublish) { + if (hasOnChainConfig || hasPendingPublish || isLinkedBuyer) { useAutoWallet = true; } } catch { // Registry check failed (e.g. RPC down). - // Fail-open only if pending publish exists (agent did `actp publish` → legitimate intent). - // Fail-closed otherwise to prevent unregistered agents getting free gas. - if (lazyPending) { + // Fail-open if pending publish OR a buyer link exists (the agent + // ran `actp publish` → legitimate intent). Fail-closed otherwise + // to prevent unregistered agents getting free gas. + if (lazyPending || buyerLink) { useAutoWallet = true; - sdkLogger.warn('AgentRegistry check failed, but pending publish found — proceeding with AA.'); + sdkLogger.warn('AgentRegistry check failed, but pending publish / buyer link found — proceeding with AA.'); } else { sdkLogger.warn('AgentRegistry check failed and no pending publish — falling back to EOA.'); } diff --git a/src/ACTPClient.walletAutoDetect.test.ts b/src/ACTPClient.walletAutoDetect.test.ts index d8753bd..572bd55 100644 --- a/src/ACTPClient.walletAutoDetect.test.ts +++ b/src/ACTPClient.walletAutoDetect.test.ts @@ -10,6 +10,7 @@ import { ACTPClient } from './ACTPClient'; import { getNetwork } from './config/networks'; import { AutoWalletProvider } from './wallet/AutoWalletProvider'; +import { loadBuyerLink } from './config/buyerLink'; // --- Mocks --- @@ -33,6 +34,11 @@ jest.mock('./runtime/BlockchainRuntime', () => ({ jest.mock('./config/pendingPublish', () => ({ loadPendingPublish: jest.fn().mockReturnValue(null), deletePendingPublish: jest.fn(), + getActpDir: jest.fn().mockReturnValue('/tmp/.actp'), +})); + +jest.mock('./config/buyerLink', () => ({ + loadBuyerLink: jest.fn().mockReturnValue(null), })); // Prevent real JsonRpcProvider startup retries/noise in unit tests. @@ -170,4 +176,49 @@ describe('ACTPClient wallet auto-detection', () => { expect(client.info.walletTier).toBe('auto'); expect(AutoWalletProvider.create).toHaveBeenCalled(); }); + + // AIP-18 DEC-8: a pure buyer has no on-chain configHash and no + // pending-publish, so the registry gate would normally drop it to EOA. The + // buyer-link marker grants the gas-sponsored auto wallet anyway. (With a mock + // provider the on-chain read throws, exercising the fail-open gate path, + // where the buyer link is honoured exactly like a pending publish.) + test('linked buyer (registry set, no configHash/pending) → auto (gasless)', async () => { + (getNetwork as jest.Mock).mockReturnValue( + makeNetworkConfig({ hasBundler: true, hasPaymaster: true }) + ); + setupAutoWalletMock(); + (loadBuyerLink as jest.Mock).mockReturnValue({ + version: 1, + slug: 'my-buyer', + wallet: '0x' + '11'.repeat(20), + linkedAt: '2026-06-06T12:00:00.000Z', + }); + + const client = await ACTPClient.create({ + mode: 'testnet', + privateKey: TEST_PRIVATE_KEY, + wallet: 'auto', + // registry left at the network default (non-empty) so the gate runs + }); + + expect(client.info.walletTier).toBe('auto'); + expect(AutoWalletProvider.create).toHaveBeenCalled(); + }); + + test('unregistered non-buyer (no configHash/pending/link) → EOA', async () => { + (getNetwork as jest.Mock).mockReturnValue( + makeNetworkConfig({ hasBundler: true, hasPaymaster: true }) + ); + setupAutoWalletMock(); + (loadBuyerLink as jest.Mock).mockReturnValue(null); + + const client = await ACTPClient.create({ + mode: 'testnet', + privateKey: TEST_PRIVATE_KEY, + wallet: 'auto', + // registry default (non-empty) → gate runs; no link → fail-closed to EOA + }); + + expect(client.info.walletTier).toBe('eoa'); + }); }); diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 8866b9c..8963822 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -24,6 +24,7 @@ import * as readline from 'readline'; import { computeConfigHash, serializeAgirailsMd, parseAgirailsMd } from '../../config/agirailsmd'; import { preparePublish, extractRegistrationParams, PENDING_ENDPOINT, defaultDiscoveryEndpoint } from '../../config/publishPipeline'; import { savePendingPublish, getActpDir } from '../../config/pendingPublish'; +import { saveBuyerLink } from '../../config/buyerLink'; import { addToGitignore, loadConfig, saveConfig, isInitialized, CLIConfig, CONFIG_DEFAULTS } from '../utils/config'; import { ethers } from 'ethers'; import { FilebaseClient } from '../../storage/FilebaseClient'; @@ -562,6 +563,21 @@ async function runPublish( 'Pay-only agent: skipping on-chain registration. ' + 'Identity is your wallet + agirails.app profile.' ); + // Write the buyer-link marker so the SDK's auto-wallet gate grants + // gas-sponsored transactions to this linked buyer (AIP-18 DEC-8) — a + // buyer has no on-chain configHash and no pending-publish, so without + // this marker the gate would fall back to the EOA wallet and require + // ETH. The marker triggers NO lazy on-chain activation. + try { + saveBuyerLink({ + version: 1, + slug: v4Config!.slug, + wallet: (walletAddress || '').toLowerCase(), + linkedAt: new Date().toISOString(), + }); + } catch { + // Best-effort — publish/link still succeeds without the gas marker. + } } else { const activationSpinner = output.spinner('Activating on testnet...'); try { diff --git a/src/config/buyerLink.test.ts b/src/config/buyerLink.test.ts new file mode 100644 index 0000000..82d2f5a --- /dev/null +++ b/src/config/buyerLink.test.ts @@ -0,0 +1,84 @@ +/** + * Buyer Link Module Tests (AIP-18 DEC-8). + * + * The buyer-link marker is the signal that lets a pure buyer (intent: pay) use + * the gas-sponsored auto wallet even though it has no on-chain configHash and + * no pending-publish. These tests cover save/load/has/delete and the + * defensive behaviours (corrupt file, missing dir, network-agnostic load). + */ + +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + BuyerLink, + saveBuyerLink, + loadBuyerLink, + hasBuyerLink, + deleteBuyerLink, + getBuyerLinkPath, +} from './buyerLink'; + +const SAMPLE: BuyerLink = { + version: 1, + slug: 'my-buyer', + wallet: '0x' + '11'.repeat(20), + linkedAt: '2026-06-06T12:00:00.000Z', +}; + +describe('buyerLink', () => { + let dir: string; + const prevActpDir = process.env.ACTP_DIR; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'buyerlink-')); + process.env.ACTP_DIR = join(dir, '.actp'); + }); + + afterEach(() => { + if (prevActpDir === undefined) delete process.env.ACTP_DIR; + else process.env.ACTP_DIR = prevActpDir; + rmSync(dir, { recursive: true, force: true }); + }); + + it('returns null / false when no marker exists', () => { + expect(loadBuyerLink()).toBeNull(); + expect(hasBuyerLink()).toBe(false); + }); + + it('saves and loads a marker (creating .actp/ on demand)', () => { + saveBuyerLink(SAMPLE); + expect(existsSync(getBuyerLinkPath())).toBe(true); + expect(loadBuyerLink()).toEqual(SAMPLE); + expect(hasBuyerLink()).toBe(true); + }); + + it('is network-agnostic — a marker saved once is found for any network', () => { + saveBuyerLink(SAMPLE); + expect(loadBuyerLink('base-sepolia')).toEqual(SAMPLE); + expect(loadBuyerLink('base-mainnet')).toEqual(SAMPLE); + expect(hasBuyerLink('base-mainnet')).toBe(true); + }); + + it('deletes the marker (and is a no-op when already absent)', () => { + saveBuyerLink(SAMPLE); + deleteBuyerLink(); + expect(loadBuyerLink()).toBeNull(); + expect(() => deleteBuyerLink()).not.toThrow(); + }); + + it('treats a corrupt marker as absent rather than crashing', () => { + saveBuyerLink(SAMPLE); + writeFileSync(getBuyerLinkPath(), '{ not valid json', 'utf-8'); + expect(loadBuyerLink()).toBeNull(); + expect(hasBuyerLink()).toBe(false); + }); + + it('writes the marker with owner-only (0o600) permissions', () => { + saveBuyerLink(SAMPLE); + const { statSync } = require('fs'); + const mode = statSync(getBuyerLinkPath()).mode & 0o777; + // 0o600 on POSIX; on platforms that don't honour mode this is best-effort. + if (process.platform !== 'win32') expect(mode).toBe(0o600); + }); +}); diff --git a/src/config/buyerLink.ts b/src/config/buyerLink.ts new file mode 100644 index 0000000..cf5ee3b --- /dev/null +++ b/src/config/buyerLink.ts @@ -0,0 +1,124 @@ +/** + * Buyer Link Module — gasless gate marker for pure buyers (AIP-18). + * + * A pure buyer (`intent: pay`) never registers on AgentRegistry and therefore + * has no on-chain `configHash` and no `pending-publish` file (DEC-3/DEC-4). + * Without a signal the SDK's auto-wallet gate (see ACTPClient) would fall back + * to the EOA wallet and the buyer would have to fund ETH — contradicting + * DEC-8 ("buyers are gasless, they need only USDC"). + * + * When `actp publish` LINKS a pay-only agent, it writes this marker. The gate + * treats its presence the same way it treats a pending-publish: proof of a + * legitimate AGIRAILS agent, so the sponsored auto wallet is used. Unlike + * pending-publish it triggers NO lazy on-chain activation — a buyer never + * registers. + * + * The marker is intentionally network-agnostic (one `buyer-link.json`): an + * agent's buyer intent does not change between testnet and mainnet, and a + * buyer's only costly on-chain action — `pay()` — locks USDC in escrow, which + * is itself the anti-DOS backstop (see threat-model). So granting the + * sponsored wallet on this marker does not open a free-gas vector. + * + * @module config/buyerLink + */ + +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, renameSync, lstatSync } from 'fs'; +import { join } from 'path'; +import { getActpDir } from './pendingPublish'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Buyer link state — saved to `.actp/buyer-link.json`. + */ +export interface BuyerLink { + /** Schema version */ + version: 1; + /** The agent's slug (for debuggability / dashboard linking) */ + slug: string; + /** The signer/EOA address that performed the link */ + wallet: string; + /** ISO 8601 timestamp of when the link was created */ + linkedAt: string; +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Path to the buyer-link marker. Network-agnostic by design. + */ +export function getBuyerLinkPath(): string { + return join(getActpDir(), 'buyer-link.json'); +} + +/** + * Save the buyer-link marker to `.actp/buyer-link.json`. + * + * Mirrors pending-publish: creates `.actp/` if missing, refuses to write + * through a symlinked directory, and writes atomically with mode 0o600. + */ +export function saveBuyerLink(link: BuyerLink): void { + const dir = getActpDir(); + + // Verify .actp/ is a real directory (symlink-attack prevention) — use + // lstatSync so a symlinked or broken-symlink .actp is rejected, not followed. + let dirExists = false; + try { + const stat = lstatSync(dir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Security: ${dir} is not a real directory (symlink attack prevention)`); + } + dirExists = true; + } catch (e: any) { + if (e.code !== 'ENOENT') throw e; + } + if (!dirExists) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const filePath = getBuyerLinkPath(); + const tmpPath = filePath + '.tmp'; + writeFileSync(tmpPath, JSON.stringify(link, null, 2), { mode: 0o600 }); + renameSync(tmpPath, filePath); +} + +/** + * Load the buyer-link marker, or null if the agent is not a linked buyer. + * + * @param network - accepted for call-site symmetry with loadPendingPublish; + * the marker is network-agnostic so the argument is ignored. + */ +export function loadBuyerLink(_network?: string): BuyerLink | null { + const filePath = getBuyerLinkPath(); + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, 'utf-8')) as BuyerLink; + } catch { + // Corrupt marker → treat as absent rather than crash client creation. + return null; + } +} + +/** Whether a buyer-link marker exists. */ +export function hasBuyerLink(network?: string): boolean { + return loadBuyerLink(network) !== null; +} + +/** + * Delete the buyer-link marker. Best-effort — never throws. + * + * Called when an agent transitions away from pure-buyer (e.g. it now publishes + * a provider config and gains a real configHash), so the marker doesn't linger. + */ +export function deleteBuyerLink(): void { + try { + const filePath = getBuyerLinkPath(); + if (existsSync(filePath)) unlinkSync(filePath); + } catch { + // Best-effort cleanup. + } +}