Skip to content

Commit eb4b2ea

Browse files
fix: bundle multi-TX routes into one signDoc for Keplr/Privy wallets
Batch routes that looped safeBroadcast() server-side were dead on client-signed (Keplr/Privy) sessions: the first chunk threw KEPLR_SIGN_REQUIRED and the per-chunk catch swallowed it, so nothing ever reached the browser to sign. A client wallet can sign exactly one signDoc per HTTP round-trip, so the only correct shape is to bundle all messages into ONE atomic TX and relay that single signDoc. Adds two helpers next to relayKeplrSign: - isClientSigned() — true for keplr/privy sessions - relayBundledOrNull(msgs, res, memo) — bundles + relays, or returns false so server-signed wallets keep the per-chunk loop Applies the bundle-or-relay guard to: - POST /api/feegrant/grant-subscribers (legacy) - POST /api/feegrant/revoke-list - POST /api/feegrant/revoke-all - POST /api/plan/add-subscribers (reuse path → bundled shares via new buildReuseShareMsgOrNull; addresses needing a new paid subscription are reported in needSubscription instead of silently failing) Other client-signed dead-ends fixed: - GET /api/feegrant/grant-subscribers-stream (SSE) now returns a JSON 400 for Keplr/Privy instead of opening a stream it can never relay through; the frontend streamGrantAll() routes those wallets to the bundled POST path instead. - _addSubscriberViaShare new-subscription path (two dependent TXs) now throws a clear, actionable error for client-signed sessions instead of relaying only the first TX and orphaning a paid-but-unshared sub. Verified live: revoke-all as a Keplr session returns one keplr-sign signDoc carrying every MsgRevokeAllowance; the SSE route returns the JSON guard; server.js node --check and the inline index.html script both parse clean.
1 parent e074d50 commit eb4b2ea

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

public/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12970,6 +12970,14 @@ <h3 id="securityFaqTitle">
1297012970

1297112971
function streamGrantAll() {
1297212972
if (!S.mgrPlanId) { toast('Select a plan first', 'error'); return; }
12973+
// Keplr/Privy wallets can't use the SSE stream — it broadcasts server-side and
12974+
// can't relay a signDoc back through an EventSource. Route them to the bundled
12975+
// POST path, which packs every grant into ONE signature the browser signs.
12976+
const kind = S.wallet?.kind;
12977+
if (kind === 'keplr' || kind === 'privy') {
12978+
grantAllSubscribers();
12979+
return;
12980+
}
1297312981
const spendLimit = parseFloat($('fgSpendLimit')?.value) || 0;
1297412982
const expDays = parseInt($('fgExpDays')?.value, 10) || 0;
1297512983
const btn = $('fgGrantAllBtn');

server.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,52 @@ function relayKeplrSign(err, res) {
16081608
return false;
16091609
}
16101610

1611+
/**
1612+
* True when the active wallet signs in the browser (Keplr extension or Privy
1613+
* enclave) rather than server-side from a mnemonic. For these sessions the
1614+
* server holds no privkey: it can build exactly ONE signDoc per HTTP round-trip
1615+
* and must relay it to the client to sign. Any route that loops safeBroadcast()
1616+
* across multiple chunks for a server-signed wallet must instead BUNDLE all
1617+
* messages into a single signDoc here — otherwise the first chunk throws
1618+
* KEPLR_SIGN_REQUIRED and a per-chunk catch silently swallows it (the bug that
1619+
* made grant-subscribers / revoke-all / revoke-list / add-subscribers dead on
1620+
* Privy + Keplr).
1621+
*/
1622+
function isClientSigned() {
1623+
const k = currentSession()?.kind;
1624+
return k === 'keplr' || k === 'privy';
1625+
}
1626+
1627+
/**
1628+
* For client-signed sessions: bundle every message into ONE TX and relay the
1629+
* resulting signDoc to the browser. Returns true if it relayed (caller must
1630+
* `return` immediately). Returns false for server-signed sessions so the caller
1631+
* falls through to its normal multi-TX server-side loop. A genuine build error
1632+
* (e.g. missing pubkey) is surfaced as JSON, not swallowed.
1633+
*
1634+
* NOTE: bundling means the whole batch is one atomic TX — all-or-nothing. That
1635+
* removes the per-chunk "retry one-by-one so the rest still go through" fallback
1636+
* the server-signed path has, but a client wallet can only sign once per
1637+
* request anyway, so atomic-batch is the only correct shape here.
1638+
*/
1639+
async function relayBundledOrNull(msgs, res, memo) {
1640+
if (!isClientSigned()) return false;
1641+
if (!msgs.length) return false;
1642+
try {
1643+
await safeBroadcast(msgs, memo);
1644+
// safeBroadcast on a client-signed session ALWAYS throws KEPLR_SIGN_REQUIRED
1645+
// before touching the chain; reaching here means it unexpectedly didn't.
1646+
return false;
1647+
} catch (err) {
1648+
if (relayKeplrSign(err, res)) return true;
1649+
// Not a sign-required signal — a real build failure (e.g. account not found,
1650+
// missing pubkey). Surface it as JSON instead of letting the caller's loop
1651+
// bury it.
1652+
res.status(400).json({ error: parseChainError(err.message || String(err)) });
1653+
return true;
1654+
}
1655+
}
1656+
16111657
// ADR-36 verification:
16121658
// - signDoc is an amino StdSignDoc with chain_id="", account_number="0",
16131659
// sequence="0", fee={gas:"0",amount:[]}, memo="", and a single
@@ -2812,6 +2858,52 @@ const DEFAULT_SHARE_BYTES = 1_000_000_000_000n; // 1 TB
28122858
* Returns { ok, subscriptionId, bytes, reused, subTx, shareTx } or throws.
28132859
* `reused` is true when no new subscription was paid for; `subTx` is null then.
28142860
*/
2861+
/**
2862+
* Build ONLY the MsgShareSubscription for `member`, reusing an existing active
2863+
* operator subscription on `planId` that holds enough spare bytes. Returns the
2864+
* proto msg object, or null when no reusable subscription can cover the
2865+
* allocation (i.e. a new paid subscription would be required — not buildable as
2866+
* a single relayable msg). Used by client-signed (Keplr/Privy) bulk add, where
2867+
* the server can't run the subscribe+share loop and must bundle share msgs into
2868+
* one signDoc. Mirrors the reuse-scan in _addSubscriberViaShare.
2869+
*/
2870+
async function buildReuseShareMsgOrNull(planId, member, { allocBytes } = {}) {
2871+
const operator = getAddr();
2872+
let requestedBytes;
2873+
try {
2874+
requestedBytes = allocBytes != null ? BigInt(allocBytes) : DEFAULT_SHARE_BYTES;
2875+
} catch {
2876+
requestedBytes = DEFAULT_SHARE_BYTES;
2877+
}
2878+
if (requestedBytes <= 0n) requestedBytes = DEFAULT_SHARE_BYTES;
2879+
2880+
const rpc = await getRpcClient();
2881+
if (!rpc) return null;
2882+
const accSubs = await rpcQuerySubscriptionsForAccount(rpc, operator, { limit: 10000 });
2883+
const planSubs = accSubs.filter(s =>
2884+
Number(s.plan_id ?? s.planId) === Number(planId) &&
2885+
(typeof s.status === 'number' ? s.status === 1 : s.status === 'active'));
2886+
let best = null;
2887+
let bestBytes = -1n;
2888+
for (const sub of planSubs) {
2889+
try {
2890+
const allocs = await rpcQuerySubscriptionAllocations(rpc, sub.id, { limit: 100 });
2891+
const own = allocs.find(a => a.address === operator);
2892+
if (!own) continue;
2893+
const have = BigInt(own.granted_bytes ?? own.grantedBytes ?? '0');
2894+
if (have > bestBytes) { bestBytes = have; best = sub; }
2895+
} catch (e) {
2896+
console.log(`[ReuseShare] allocations for sub ${sub.id} failed: ${e.message}`);
2897+
}
2898+
}
2899+
if (!best || bestBytes < requestedBytes) return null;
2900+
const shareBytes = requestedBytes > bestBytes ? bestBytes : requestedBytes;
2901+
return {
2902+
typeUrl: C.MSG_SHARE_SUBSCRIPTION_TYPE,
2903+
value: { from: operator, id: BigInt(String(best.id)), accAddress: member, bytes: String(shareBytes) },
2904+
};
2905+
}
2906+
28152907
async function _addSubscriberViaShare(planId, member, { denom = 'udvpn', allocBytes } = {}) {
28162908
const operator = getAddr();
28172909
let requestedBytes;
@@ -2869,6 +2961,22 @@ async function _addSubscriberViaShare(planId, member, { denom = 'udvpn', allocBy
28692961
// 2. No reusable subscription — operator self-subscribes (pays the plan price).
28702962
// Use the operator's own fee-grant grantor for gas if one is configured.
28712963
if (!reused) {
2964+
// Client-signed wallets (Keplr/Privy): the self-subscribe path needs TWO
2965+
// dependent on-chain TXs — MsgStartSubscription, then (after reading the new
2966+
// subscription_id back from its events) MsgShareSubscription. A client wallet
2967+
// can sign exactly one signDoc per HTTP round-trip, so the share could never
2968+
// fire and we'd leave a paid-but-unshared subscription. Fail loudly with an
2969+
// actionable message instead of starting an un-completable flow. The reuse
2970+
// path above is a single share TX and relays fine through the route's catch.
2971+
if (isClientSigned()) {
2972+
const e = new Error(
2973+
`No existing subscription on plan ${planId} can cover this allocation. ` +
2974+
`With a Keplr/Privy wallet, first subscribe to the plan yourself (Subscribe), ` +
2975+
`then add members — sharing from an existing subscription needs only one signature.`
2976+
);
2977+
e.clientSignedTwoTx = true;
2978+
throw e;
2979+
}
28722980
const subMsg = {
28732981
typeUrl: C.MSG_START_SUBSCRIPTION_TYPE,
28742982
value: { from: operator, id: BigInt(planId), denom, renewalPricePolicy: 0 },
@@ -2946,6 +3054,7 @@ app.post('/api/plan/add-subscriber', async (req, res) => {
29463054
res.json(result);
29473055
} catch (err) {
29483056
if (relayKeplrSign(err, res)) return;
3057+
if (err.clientSignedTwoTx) return res.status(400).json({ error: err.message, needSubscription: true });
29493058
console.error('Add subscriber error:', err.message);
29503059
res.status(500).json({ error: parseChainError(err.message) });
29513060
}
@@ -2961,6 +3070,43 @@ app.post('/api/plan/add-subscribers', async (req, res) => {
29613070
: [];
29623071
if (!list.length) return res.status(400).json({ error: 'addresses[] with valid sent1... entries required' });
29633072

3073+
// Client-signed wallets (Keplr/Privy): the server holds no key, so it can't
3074+
// run the per-address subscribe+share loop below (each address needs its own
3075+
// signature, and a self-subscribe needs two dependent TXs). Instead, build a
3076+
// single share msg per address from an EXISTING reusable subscription and
3077+
// bundle them all into ONE signDoc to relay. Any address that would require a
3078+
// new (paid) subscription is reported back so the operator can subscribe
3079+
// first — sharing then needs only one signature.
3080+
if (isClientSigned()) {
3081+
const planNum = parseInt(planId, 10);
3082+
const shareMsgs = [];
3083+
const needSubscription = [];
3084+
for (const addr of list) {
3085+
try {
3086+
const msg = await buildReuseShareMsgOrNull(planNum, addr, { allocBytes });
3087+
if (msg) shareMsgs.push(msg);
3088+
else needSubscription.push(addr);
3089+
} catch (e) {
3090+
needSubscription.push(addr);
3091+
console.log(`[add-subscribers] reuse-share build for ${addr} failed: ${e.message}`);
3092+
}
3093+
}
3094+
if (!shareMsgs.length) {
3095+
return res.status(400).json({
3096+
error: `No existing subscription on plan ${planNum} can cover these allocations. ` +
3097+
`With a Keplr/Privy wallet, subscribe to the plan yourself first, then add members.`,
3098+
needSubscription,
3099+
});
3100+
}
3101+
// Stash the addresses that couldn't be bundled so the relay caller can see
3102+
// them — relayBundledOrNull writes the signDoc response, so attach via a
3103+
// header the frontend ignores but logs surface.
3104+
if (needSubscription.length) {
3105+
console.log(`[add-subscribers] ${needSubscription.length} address(es) need a new subscription, not bundled: ${needSubscription.join(', ')}`);
3106+
}
3107+
if (await relayBundledOrNull(shareMsgs, res, `Share to ${shareMsgs.length} members of plan ${planNum}`)) return;
3108+
}
3109+
29643110
const results = [];
29653111
// Sequential — each address needs its own subscribe+share, and back-to-back
29663112
// signing from one wallet must serialize to avoid account-sequence collisions.
@@ -4099,6 +4245,19 @@ app.get('/api/feegrant/grant-subscribers-stream', async (req, res) => {
40994245
}
41004246
}
41014247

4248+
// Client-signed wallets (Keplr/Privy) can't use this SSE path: once the
4249+
// stream opens we can no longer return a signDoc for the browser to sign, and
4250+
// an EventSource can't carry a signature back anyway. Refuse here with a JSON
4251+
// 400 (headers not yet written) so the frontend falls back to the POST
4252+
// /api/feegrant/grant-subscribers route, which bundles every grant into one
4253+
// signDoc and relays it for a single client-side signature.
4254+
if (isClientSigned()) {
4255+
return res.status(400).json({
4256+
error: 'Streaming fee-grant is unavailable for Keplr/Privy wallets — use the bundled grant instead.',
4257+
errorCode: 'use-bundled-grant',
4258+
});
4259+
}
4260+
41024261
res.writeHead(200, {
41034262
'Content-Type': 'text/event-stream',
41044263
'Cache-Control': 'no-cache',
@@ -4310,6 +4469,14 @@ app.post('/api/feegrant/grant-subscribers', async (req, res) => {
43104469
opts.expiration = new Date(Date.now() + expirationDays * 86400000);
43114470
}
43124471

4472+
// Client-signed wallets (Keplr/Privy) can't run the per-chunk server loop
4473+
// below — the server holds no key. Bundle every grant into ONE signDoc and
4474+
// relay it for a single client-side signature.
4475+
if (isClientSigned()) {
4476+
const allMsgs = needGrant.map(grantee => buildFeeGrantMsg(getAddr(), grantee, opts));
4477+
if (await relayBundledOrNull(allMsgs, res, `Fee grant ${needGrant.length} subscribers`)) return;
4478+
}
4479+
43134480
const BATCH = 5;
43144481
const totalBatches = Math.ceil(needGrant.length / BATCH);
43154482
let granted = 0;
@@ -4380,6 +4547,12 @@ app.post('/api/feegrant/revoke-list', async (req, res) => {
43804547
if (list.length === 0) return res.status(400).json({ error: 'no valid grantees in list' });
43814548

43824549
try {
4550+
// Client-signed wallets: bundle all revokes into ONE signDoc to relay.
4551+
if (isClientSigned()) {
4552+
const allMsgs = list.map(grantee => buildRevokeFeeGrantMsg(getAddr(), grantee));
4553+
if (await relayBundledOrNull(allMsgs, res, `Revoke ${list.length} grants`)) return;
4554+
}
4555+
43834556
const BATCH = 5;
43844557
let revoked = 0;
43854558
let alreadyGone = 0;
@@ -4451,6 +4624,12 @@ app.post('/api/feegrant/revoke-all', async (req, res) => {
44514624
return res.json({ ok: true, revoked: 0, alreadyGone: 0, message: 'No grants to revoke' });
44524625
}
44534626

4627+
// Client-signed wallets: bundle all revokes into ONE signDoc to relay.
4628+
if (isClientSigned()) {
4629+
const allMsgs = grantees.map(grantee => buildRevokeFeeGrantMsg(getAddr(), grantee));
4630+
if (await relayBundledOrNull(allMsgs, res, `Revoke ${grantees.length} grants`)) return;
4631+
}
4632+
44544633
const BATCH = 5;
44554634
let revoked = 0;
44564635
let alreadyGone = 0;

0 commit comments

Comments
 (0)