Skip to content
Merged
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
34 changes: 28 additions & 6 deletions src/ACTPClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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.');
}
Expand Down
51 changes: 51 additions & 0 deletions src/ACTPClient.walletAutoDetect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { ACTPClient } from './ACTPClient';
import { getNetwork } from './config/networks';
import { AutoWalletProvider } from './wallet/AutoWalletProvider';
import { loadBuyerLink } from './config/buyerLink';

// --- Mocks ---

Expand All @@ -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.
Expand Down Expand Up @@ -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');
});
});
16 changes: 16 additions & 0 deletions src/cli/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions src/config/buyerLink.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
124 changes: 124 additions & 0 deletions src/config/buyerLink.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
Loading