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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-score/commerce",
"version": "1.3.0",
"version": "1.3.1",
"description": "Agent commerce SDK — identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce.",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
85 changes: 74 additions & 11 deletions src/payment/mppx_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,23 @@ export async function createMppxServer(opts: CreateMppxServerOptions): Promise<u
network === 'mainnet-beta' ? USDC.solana.mainnet.mint : USDC.solana.devnet.mint;
const defaultDecimals =
network === 'mainnet-beta' ? USDC.solana.mainnet.decimals : USDC.solana.devnet.decimals;
methods.push(
solanaMpp.charge({
recipient: s.recipient,
currency: s.currency ?? defaultMint,
decimals: s.decimals ?? defaultDecimals,
network,
...(s.rpcUrl ? { rpcUrl: s.rpcUrl } : {}),
...(s.signer ? { signer: s.signer } : {}),
...(s.tokenProgram ? { tokenProgram: s.tokenProgram } : {}),
}),
);
const baseMethod = solanaMpp.charge({
recipient: s.recipient,
currency: s.currency ?? defaultMint,
decimals: s.decimals ?? defaultDecimals,
network,
...(s.rpcUrl ? { rpcUrl: s.rpcUrl } : {}),
...(s.signer ? { signer: s.signer } : {}),
...(s.tokenProgram ? { tokenProgram: s.tokenProgram } : {}),
}) as SolanaChargeMethod;
const rpcUrl =
s.rpcUrl ??
(network === 'mainnet-beta'
? 'https://api.mainnet-beta.solana.com'
: network === 'devnet'
? 'https://api.devnet.solana.com'
: 'http://localhost:8899');
methods.push(wrapSolanaChargeWithFinalizedBlockhash(baseMethod, rpcUrl));
}

if (opts.rails?.stripe) {
Expand All @@ -219,3 +225,60 @@ async function dynamicImport<T>(moduleName: string): Promise<T | null> {
return null;
}
}

type SolanaChargeRequestArgs = { credential?: unknown; request?: unknown };
type SolanaChargeMethod = {
request?: (args: SolanaChargeRequestArgs) => Promise<unknown>;
} & Record<string, unknown>;

/**
* Wraps `@solana/mpp.charge()`'s Method so the issued challenge carries a
* `finalized` blockhash instead of `confirmed`.
*
* `@solana/mpp` <= 0.5.2 fetches `getLatestBlockhash` with `commitment: 'confirmed'`
* but its broadcast `sendTransaction` sets `skipPreflight: false` without an
* overridden `preflightCommitment`. The RPC server's default preflight commitment
* is `finalized`, which rejects any blockhash that hasn't yet finalized with a
* "Blockhash not found" error. Handing the client a `finalized` blockhash up
* front sidesteps the mismatch.
*
* Trade-off: the signing window shrinks from ~58s (confirmed) to ~46s (finalized).
* Fine for agent-driven flows; manual signing flows still have plenty of margin.
*/
export function wrapSolanaChargeWithFinalizedBlockhash(
baseMethod: SolanaChargeMethod,
rpcUrl: string,
): SolanaChargeMethod {
return {
...baseMethod,
async request(args: SolanaChargeRequestArgs) {
const orig = (await baseMethod.request!(args)) as
| { methodDetails?: Record<string, unknown> }
| undefined;
if (args.credential || !orig || typeof orig !== 'object') return orig;
try {
const res = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 1,
jsonrpc: '2.0',
method: 'getLatestBlockhash',
params: [{ commitment: 'finalized' }],
}),
});
const data = (await res.json()) as { result?: { value?: { blockhash?: string } } };
const finalized = data?.result?.value?.blockhash;
if (finalized) {
return {
...orig,
methodDetails: { ...(orig.methodDetails ?? {}), recentBlockhash: finalized },
};
}
} catch {
/* fall back to upstream's confirmed blockhash */
}
return orig;
},
};
}
28 changes: 28 additions & 0 deletions tests/payment/mppx_server_extras.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,32 @@ describe('createMppxServer — additional rail branches', () => {
});
expect(server).toBeDefined();
});

it('registers solana rail and wraps charge() with finalized-blockhash request override', async () => {
const server = await createMppxServer({
rails: {
solana: {
recipient: 'JDK3GZwsmgWwdFicNnrLHEgZc54SNYp6egWL9LL3k9f5',
network: 'devnet',
rpcUrl: 'http://localhost:9999',
},
},
secretKey: 'mpp_secret_xxx',
});
expect(server).toBeDefined();
});

it('solana rail with mainnet network + custom token program', async () => {
const server = await createMppxServer({
rails: {
solana: {
recipient: 'JDK3GZwsmgWwdFicNnrLHEgZc54SNYp6egWL9LL3k9f5',
network: 'mainnet-beta',
tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
},
},
secretKey: 'mpp_secret_xxx',
});
expect(server).toBeDefined();
});
});
89 changes: 89 additions & 0 deletions tests/payment/wrap_solana_charge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { wrapSolanaChargeWithFinalizedBlockhash } from '../../src/payment/mppx_server';

describe('wrapSolanaChargeWithFinalizedBlockhash', () => {
const rpcUrl = 'http://rpc.test';
const baseRequestResult = {
methodDetails: { recentBlockhash: 'CONFIRMED_HASH', network: 'devnet' },
recipient: 'JDK3GZwsmgWwdFicNnrLHEgZc54SNYp6egWL9LL3k9f5',
};

let originalFetch: typeof fetch | undefined;
let baseMethod: { request: ReturnType<typeof vi.fn> };

beforeEach(() => {
originalFetch = globalThis.fetch;
baseMethod = { request: vi.fn(async () => baseRequestResult) };
});

afterEach(() => {
if (originalFetch) globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});

it("replaces recentBlockhash with the RPC's finalized blockhash", async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({ result: { value: { blockhash: 'FINALIZED_HASH' } } }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;

const wrapped = wrapSolanaChargeWithFinalizedBlockhash(baseMethod, rpcUrl);
const result = (await wrapped.request!({ request: {} })) as {
methodDetails: Record<string, unknown>;
};

expect(result.methodDetails.recentBlockhash).toBe('FINALIZED_HASH');
expect(fetchMock).toHaveBeenCalledOnce();
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body.method).toBe('getLatestBlockhash');
expect(body.params[0].commitment).toBe('finalized');
});

it('passes through unchanged when args.credential is set (verify path)', async () => {
const fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;

const wrapped = wrapSolanaChargeWithFinalizedBlockhash(baseMethod, rpcUrl);
const result = await wrapped.request!({ credential: { foo: 'bar' } });

expect(result).toBe(baseRequestResult);
expect(fetchMock).not.toHaveBeenCalled();
});

it('falls back to the upstream confirmed blockhash when fetch throws', async () => {
globalThis.fetch = vi.fn(async () => {
throw new Error('rpc unreachable');
}) as unknown as typeof fetch;

const wrapped = wrapSolanaChargeWithFinalizedBlockhash(baseMethod, rpcUrl);
const result = (await wrapped.request!({ request: {} })) as {
methodDetails: { recentBlockhash: string };
};

expect(result.methodDetails.recentBlockhash).toBe('CONFIRMED_HASH');
});

it('falls back when the RPC returns no blockhash', async () => {
globalThis.fetch = vi.fn(
async () => new Response(JSON.stringify({ result: { value: {} } }), { status: 200 }),
) as unknown as typeof fetch;

const wrapped = wrapSolanaChargeWithFinalizedBlockhash(baseMethod, rpcUrl);
const result = (await wrapped.request!({ request: {} })) as {
methodDetails: { recentBlockhash: string };
};

expect(result.methodDetails.recentBlockhash).toBe('CONFIRMED_HASH');
});

it('returns the upstream value as-is when it is null/undefined', async () => {
baseMethod.request.mockResolvedValueOnce(undefined);
const wrapped = wrapSolanaChargeWithFinalizedBlockhash(baseMethod, rpcUrl);
const result = await wrapped.request!({ request: {} });
expect(result).toBeUndefined();
});
});
Loading