Skip to content

Commit 68a8ba5

Browse files
fix(server): revoke stale fee grant so re-paying agents can provision
MsgGrantAllowance hard-fails when the grantee already holds a fee grant, and atomicity takes MsgShareSubscription down with it - any agent that bought a plan before could never buy again. Observed live 2026-06-12: HTTP 500 PROVISIONING_FAILED "fee allowance already exists ... message index: 1" on every repeat purchase (charged:false, so no funds lost). provisionAgent now broadcasts through broadcastProvision(): try [share, grant] as before; if the failure log contains "fee allowance already exists", retry once with [share, revoke, grant] in a single atomic TX. The replacement grant carries the new purchase's spend limit and expiry. Fresh agents never pay the extra round-trip. Verified on sentinelhub-2 (sequential, 7s gaps): - duplicate MsgGrantAllowance rejects with the exact matched string (thrown error path), TX 4CFA4FC2... - atomic [MsgRevokeAllowance, MsgGrantAllowance] succeeds, TX 280562E1... (test grant revoked after, TX 8085F9AC...)
1 parent aea6db9 commit 68a8ba5

1 file changed

Lines changed: 43 additions & 6 deletions

File tree

server/src/sentinel.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ const buildFeeGrantMsg = (sdk as any).buildFeeGrantMsg as (
5454
},
5555
) => EncodedMsg;
5656

57+
const buildRevokeFeeGrantMsg = (sdk as any).buildRevokeFeeGrantMsg as (
58+
granter: string, grantee: string,
59+
) => EncodedMsg;
60+
5761
// RPC query helpers (RPC-first per global rules) — exported from blue-js-sdk at
5862
// runtime but missing from types/index.d.ts, same situation as buildMsg* above.
5963
const createRpcQueryClient = (sdk as any).createRpcQueryClient as
@@ -310,8 +314,8 @@ export async function initSentinel(): Promise<{ address: string; planId: number
310314
}
311315

312316
// Verify buildMsg functions loaded
313-
if (!buildMsgShareSubscription || !buildMsgStartSubscription || !buildFeeGrantMsg) {
314-
throw new Error('blue-js-sdk missing required exports (buildMsgShareSubscription, buildFeeGrantMsg)');
317+
if (!buildMsgShareSubscription || !buildMsgStartSubscription || !buildFeeGrantMsg || !buildRevokeFeeGrantMsg) {
318+
throw new Error('blue-js-sdk missing required exports (buildMsgShareSubscription, buildFeeGrantMsg, buildRevokeFeeGrantMsg)');
315319
}
316320

317321
const { wallet, account } = await createWallet(mnemonic);
@@ -433,14 +437,47 @@ export interface ProvisionResult {
433437
instructions: string;
434438
}
435439

440+
// MsgGrantAllowance hard-fails when the grantee already holds a fee grant
441+
// (a re-paying agent), and atomicity takes MsgShareSubscription down with it —
442+
// observed live 2026-06-12 as a 500 PROVISIONING_FAILED on every repeat
443+
// purchase. Retry once with MsgRevokeAllowance prepended so the stale grant is
444+
// replaced (fresh spend limit + expiry) in the same atomic TX. Fresh agents
445+
// never pay the extra round-trip.
446+
const GRANT_EXISTS = 'fee allowance already exists';
447+
448+
async function broadcastProvision(
449+
shareMsg: EncodedMsg,
450+
feeGrantMsg: EncodedMsg,
451+
sentinelAddr: string,
452+
memo: string,
453+
): Promise<BroadcastResult> {
454+
let result: BroadcastResult | null = null;
455+
let thrown: Error | null = null;
456+
try {
457+
result = await safeBroadcast!([shareMsg, feeGrantMsg], memo);
458+
} catch (err) {
459+
thrown = err as Error;
460+
}
461+
462+
const log = thrown ? thrown.message || '' : result!.code === 0 ? '' : result!.rawLog || '';
463+
if (!log.includes(GRANT_EXISTS)) {
464+
if (thrown) throw thrown;
465+
return result!;
466+
}
467+
468+
console.log(`[sentinel] ${sentinelAddr} already has a fee grant — revoking and re-granting...`);
469+
const revokeMsg = buildRevokeFeeGrantMsg(operatorAddress, sentinelAddr);
470+
return safeBroadcast!([shareMsg, revokeMsg, feeGrantMsg], memo);
471+
}
472+
436473
/**
437474
* Provision VPN access for an agent on the Sentinel chain.
438475
*
439476
* 1. Get available subscription (or create one)
440477
* 2. Share subscription with agent's Sentinel address
441-
* 3. Grant fee allowance so agent pays 0 gas
478+
* 3. Grant fee allowance so agent pays 0 gas (revoking any stale grant first)
442479
*
443-
* Both messages batched into a single TX for atomicity.
480+
* All messages batched into a single TX for atomicity.
444481
*/
445482
export async function provisionAgent(
446483
sentinelAddr: string,
@@ -477,7 +514,7 @@ export async function provisionAgent(
477514
console.log(`[sentinel] Provisioning ${days}d for ${sentinelAddr} (sub ${slot.id})...`);
478515

479516
try {
480-
const result = await safeBroadcast([shareMsg, feeGrantMsg], `x402 provision ${days}d`);
517+
const result = await broadcastProvision(shareMsg, feeGrantMsg, sentinelAddr, `x402 provision ${days}d`);
481518

482519
if (result.code === 0) {
483520
slot.allocations++;
@@ -532,7 +569,7 @@ export async function provisionAgent(
532569
bytes: SHARE_BYTES,
533570
});
534571

535-
const result = await safeBroadcast([shareMsg, feeGrantMsg], `x402 provision ${days}d`);
572+
const result = await broadcastProvision(shareMsg, feeGrantMsg, sentinelAddr, `x402 provision ${days}d`);
536573
if (result.code !== 0) {
537574
throw new Error(`Sentinel TX failed on new sub ${subscriptionId} (code ${result.code}): ${result.rawLog}`);
538575
}

0 commit comments

Comments
 (0)