diff --git a/package.json b/package.json index 57a685a..e86fddc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/payment/mppx_server.ts b/src/payment/mppx_server.ts index e227f78..a95074c 100644 --- a/src/payment/mppx_server.ts +++ b/src/payment/mppx_server.ts @@ -191,17 +191,23 @@ export async function createMppxServer(opts: CreateMppxServerOptions): Promise(moduleName: string): Promise { return null; } } + +type SolanaChargeRequestArgs = { credential?: unknown; request?: unknown }; +type SolanaChargeMethod = { + request?: (args: SolanaChargeRequestArgs) => Promise; +} & Record; + +/** + * 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 } + | 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; + }, + }; +} diff --git a/tests/payment/mppx_server_extras.test.ts b/tests/payment/mppx_server_extras.test.ts index 599b8fc..aee8366 100644 --- a/tests/payment/mppx_server_extras.test.ts +++ b/tests/payment/mppx_server_extras.test.ts @@ -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(); + }); }); diff --git a/tests/payment/wrap_solana_charge.test.ts b/tests/payment/wrap_solana_charge.test.ts new file mode 100644 index 0000000..f1a93bb --- /dev/null +++ b/tests/payment/wrap_solana_charge.test.ts @@ -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 }; + + 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; + }; + + 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(); + }); +});