-
Notifications
You must be signed in to change notification settings - Fork 89
Feat/naga capacity delegation authsig #1044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: naga
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!privateKey) { | ||
| const expectedKey = | ||
| network === 'naga' | ||
| ? 'LIVE_MASTER_ACCOUNT_NAGA or LIVE_MASTER_ACCOUNT' | ||
| : selectedNetwork === 'local' | ||
| ? 'LOCAL_MASTER_ACCOUNT' | ||
| : 'LIVE_MASTER_ACCOUNT'; | ||
| const scopedKey = `LIVE_MASTER_ACCOUNT_${network | ||
| .toUpperCase() | ||
| .replace(/-/g, '_')}`; | ||
| throw new Error( | ||
| `❌ ${expectedKey} env var is not set for NETWORK=${network}.` | ||
| `❌ You are on "${selectedNetwork}" environment, network ${network}. We are expecting ${testEnv.live.key} or ${scopedKey} to be set.` | ||
| ); | ||
| } |
Copilot
AI
Jan 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message incorrectly references testEnv.live.key when privateKey is missing for local networks. For local networks, only LOCAL_MASTER_ACCOUNT is expected, not LIVE_MASTER_ACCOUNT or scoped keys. Consider adding a conditional check for selectedNetwork to provide the correct error message for local vs. live networks.
| import { ethers } from 'ethers'; | ||
|
|
||
| import { InvalidArgumentException, LIT_ABILITY } from '@lit-protocol/constants'; | ||
| import { | ||
| AuthSig, | ||
| PaymentDelegationAuthSigData, | ||
| PaymentDelegationAuthSigParams, | ||
| PaymentDelegationScope, | ||
| } from '@lit-protocol/types'; | ||
|
|
||
| import { generateAuthSig, generateAuthSigWithViem } from './generate-auth-sig'; | ||
| import { LitPaymentDelegationResource } from './resources'; | ||
| import { createSiweMessage } from './siwe/create-siwe-message'; | ||
|
|
||
| const PAYMENT_DELEGATION_SCOPES = new Set<PaymentDelegationScope>([ | ||
| 'encryption_sign', | ||
| 'lit_action', | ||
| 'pkp_sign', | ||
| 'sign_session_key', | ||
| ]); | ||
|
|
||
| type ViemSigner = Parameters<typeof generateAuthSigWithViem>[0]['account']; | ||
|
|
||
| const normalizeDelegateeAddresses = (delegateeAddresses: string[]) => { | ||
| return delegateeAddresses.map((address) => { | ||
| if (!address || typeof address !== 'string') { | ||
| throw new InvalidArgumentException( | ||
| { info: { delegateeAddresses } }, | ||
| 'delegateeAddresses must be a non-empty array of strings' | ||
| ); | ||
| } | ||
|
|
||
| const trimmed = address.trim(); | ||
| const prefixed = trimmed.startsWith('0x') ? trimmed : `0x${trimmed}`; | ||
| const checksummed = ethers.utils.getAddress(prefixed); | ||
|
|
||
| return checksummed.toLowerCase().replace(/^0x/, ''); | ||
| }); | ||
| }; | ||
|
|
||
| const normalizeScopes = (scopes: PaymentDelegationScope[]) => { | ||
| if (!Array.isArray(scopes) || scopes.length === 0) { | ||
| throw new InvalidArgumentException( | ||
| { info: { scopes } }, | ||
| 'scopes must be a non-empty array' | ||
| ); | ||
| } | ||
|
|
||
| const normalizedScopes = Array.from(new Set(scopes)); | ||
|
|
||
| for (const scope of normalizedScopes) { | ||
| if (!PAYMENT_DELEGATION_SCOPES.has(scope)) { | ||
| throw new InvalidArgumentException( | ||
| { info: { scope } }, | ||
| `Unsupported payment delegation scope: ${scope}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return normalizedScopes; | ||
| }; | ||
|
|
||
| const normalizeMaxPrice = ( | ||
| maxPrice: PaymentDelegationAuthSigParams['maxPrice'] | ||
| ) => { | ||
| if (maxPrice === null || maxPrice === undefined) { | ||
| throw new InvalidArgumentException( | ||
| { info: { maxPrice } }, | ||
| 'maxPrice is required' | ||
| ); | ||
| } | ||
|
|
||
| if (typeof maxPrice === 'bigint') { | ||
| if (maxPrice < 0n) { | ||
| throw new InvalidArgumentException( | ||
| { info: { maxPrice } }, | ||
| 'maxPrice must be non-negative' | ||
| ); | ||
| } | ||
| return maxPrice.toString(16); | ||
| } | ||
|
|
||
| if (typeof maxPrice === 'number') { | ||
| if (!Number.isFinite(maxPrice) || maxPrice < 0) { | ||
| throw new InvalidArgumentException( | ||
| { info: { maxPrice } }, | ||
| 'maxPrice must be a finite, non-negative number' | ||
| ); | ||
| } | ||
| return BigInt(Math.trunc(maxPrice)).toString(16); | ||
| } | ||
|
|
||
| const trimmed = maxPrice.trim(); | ||
| if (trimmed.length === 0) { | ||
| throw new InvalidArgumentException( | ||
| { info: { maxPrice } }, | ||
| 'maxPrice must not be empty' | ||
| ); | ||
| } | ||
|
|
||
| if (trimmed.startsWith('0x') || trimmed.startsWith('0X')) { | ||
| return trimmed.slice(2).toLowerCase(); | ||
| } | ||
|
|
||
| if (/^[0-9]+$/.test(trimmed)) { | ||
| return BigInt(trimmed).toString(16); | ||
| } | ||
|
|
||
| if (/^[0-9a-fA-F]+$/.test(trimmed)) { | ||
| return trimmed.toLowerCase(); | ||
| } | ||
|
|
||
| throw new InvalidArgumentException( | ||
| { info: { maxPrice } }, | ||
| 'maxPrice must be a hex or decimal string' | ||
| ); | ||
| }; | ||
|
|
||
| const resolveNonce = async (params: PaymentDelegationAuthSigParams) => { | ||
| if (params.nonce !== undefined) { | ||
| if (typeof params.nonce !== 'string') { | ||
| throw new InvalidArgumentException( | ||
| { info: { nonce: params.nonce } }, | ||
| 'nonce must be a string' | ||
| ); | ||
| } | ||
|
|
||
| const trimmed = params.nonce.trim(); | ||
| if (!trimmed) { | ||
| throw new InvalidArgumentException( | ||
| { info: { nonce: params.nonce } }, | ||
| 'nonce must not be empty' | ||
| ); | ||
| } | ||
| return trimmed; | ||
| } | ||
|
|
||
| if (params.litClient && typeof params.litClient.getContext === 'function') { | ||
| const context = await params.litClient.getContext(); | ||
| if (context?.latestBlockhash) { | ||
| return context.latestBlockhash; | ||
| } | ||
|
|
||
| throw new InvalidArgumentException( | ||
| { info: { context } }, | ||
| 'litClient.getContext() did not return latestBlockhash' | ||
| ); | ||
| } | ||
|
|
||
| throw new InvalidArgumentException( | ||
| { info: { nonce: params.nonce, hasLitClient: Boolean(params.litClient) } }, | ||
| 'nonce is required; provide nonce or litClient' | ||
| ); | ||
| }; | ||
|
|
||
| const resolveSignerAddress = async (params: PaymentDelegationAuthSigParams) => { | ||
| if (params.signerAddress) { | ||
| const prefixed = params.signerAddress.startsWith('0x') | ||
| ? params.signerAddress | ||
| : `0x${params.signerAddress}`; | ||
| return ethers.utils.getAddress(prefixed); | ||
| } | ||
|
|
||
| if ( | ||
| 'getAddress' in params.signer && | ||
| typeof params.signer.getAddress === 'function' | ||
| ) { | ||
| return ethers.utils.getAddress(await params.signer.getAddress()); | ||
| } | ||
|
|
||
| if ( | ||
| 'address' in params.signer && | ||
| typeof (params.signer as { address?: string }).address === 'string' | ||
| ) { | ||
| return ethers.utils.getAddress( | ||
| (params.signer as { address: string }).address | ||
| ); | ||
| } | ||
|
|
||
| throw new InvalidArgumentException( | ||
| { info: { signer: params.signer } }, | ||
| 'signerAddress is required when signer does not expose an address' | ||
| ); | ||
| }; | ||
|
|
||
| export const createPaymentDelegationAuthSig = async ( | ||
| params: PaymentDelegationAuthSigParams | ||
| ): Promise<AuthSig> => { | ||
| if (!params.signer) { | ||
| throw new InvalidArgumentException( | ||
| { info: { signer: params.signer } }, | ||
| 'signer is required' | ||
| ); | ||
| } | ||
|
|
||
| if (!params.delegateeAddresses || params.delegateeAddresses.length === 0) { | ||
| throw new InvalidArgumentException( | ||
| { info: { delegateeAddresses: params.delegateeAddresses } }, | ||
| 'delegateeAddresses must be provided' | ||
| ); | ||
| } | ||
|
|
||
| const signerAddress = await resolveSignerAddress(params); | ||
| const delegateeAddresses = normalizeDelegateeAddresses( | ||
| params.delegateeAddresses | ||
| ); | ||
| const scopes = normalizeScopes(params.scopes); | ||
| const maxPrice = normalizeMaxPrice(params.maxPrice); | ||
| const nonce = await resolveNonce(params); | ||
|
|
||
| const data: PaymentDelegationAuthSigData = { | ||
| delegate_to: delegateeAddresses, | ||
| max_price: maxPrice, | ||
| scopes, | ||
| }; | ||
|
|
||
| const siweMessage = await createSiweMessage({ | ||
| walletAddress: signerAddress, | ||
| nonce, | ||
| expiration: params.expiration, | ||
| domain: params.domain, | ||
| statement: params.statement, | ||
| uri: 'lit:capability:delegation', | ||
| resources: [ | ||
| { | ||
| resource: new LitPaymentDelegationResource('*'), | ||
| ability: LIT_ABILITY.PaymentDelegation, | ||
| data, | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| if ( | ||
| !('getAddress' in params.signer) && | ||
| 'address' in params.signer && | ||
| typeof (params.signer as { address?: string }).address === 'string' | ||
| ) { | ||
| return generateAuthSigWithViem({ | ||
| account: params.signer as ViemSigner, | ||
| toSign: siweMessage, | ||
| address: signerAddress, | ||
| }); | ||
| } | ||
|
|
||
| return generateAuthSig({ | ||
| signer: params.signer, | ||
| toSign: siweMessage, | ||
| address: signerAddress, | ||
| }); | ||
| }; | ||
|
|
||
| export { PAYMENT_DELEGATION_SCOPES }; |
Copilot
AI
Jan 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The payment-delegation module contains complex validation logic (normalizeDelegateeAddresses, normalizeScopes, normalizeMaxPrice, resolveNonce, resolveSignerAddress) but lacks unit tests. Given that this package has existing unit test coverage (e.g., auth-config-builder.spec.ts), consider adding unit tests for the validation functions to ensure edge cases are handled correctly, especially for input validation and error conditions.
| const scopedKey = `LIVE_MASTER_ACCOUNT_${network | ||
| .toUpperCase() | ||
| .replace(/-/g, '_')}`; | ||
| privateKey = | ||
| (process.env[legacyKey] as `0x${string}` | undefined) ?? | ||
| (process.env[scopedKey] as `0x${string}` | undefined); | ||
| } | ||
|
|
||
| if (!privateKey) { | ||
| const expectedKey = | ||
| network === 'naga' | ||
| ? 'LIVE_MASTER_ACCOUNT_NAGA or LIVE_MASTER_ACCOUNT' | ||
| : selectedNetwork === 'local' | ||
| ? 'LOCAL_MASTER_ACCOUNT' | ||
| : 'LIVE_MASTER_ACCOUNT'; | ||
| const scopedKey = `LIVE_MASTER_ACCOUNT_${network | ||
| .toUpperCase() | ||
| .replace(/-/g, '_')}`; |
Copilot
AI
Jan 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The scopedKey is computed twice: once at lines 62-64 and again at lines 71-73. Consider extracting this computation into a variable before the privateKey assignment to avoid duplication and improve code maintainability.
| export type PaymentDelegationSigner = | ||
| | SignerLike | ||
| | { | ||
| signMessage: (message: string | any) => Promise<string>; |
Copilot
AI
Jan 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The signMessage method signature accepts string | any, which is overly permissive. Consider using a more specific type instead of any to maintain type safety. For example, string | Uint8Array or another appropriate type based on what message formats are actually supported.
| signMessage: (message: string | any) => Promise<string>; | |
| signMessage: (message: string | Uint8Array) => Promise<string>; |
WHAT
Introduce Payment Delegation Auth Sig
Session-scoped delegation (no on-chain delegation)
If you need a one-off or short-lived sponsorship without calling
delegatePayments*, issue a payment delegation AuthSig and attach it to the user's auth config. The SIWE expiration scopes how long the delegation is valid.Scenario example:
pkpSigncall.pkp_signfor Bob.capabilityAuthSigsand callspkpSign.TEST
Commands