@@ -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+
28152907async 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