Skip to content

Conversation

@Ansonhkg
Copy link
Collaborator

@Ansonhkg Ansonhkg commented Jan 7, 2026

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:

  • Bob has a PKP but no ledger balance.
  • Alice has ledger funds and wants to sponsor a single pkpSign call.
  • Alice creates a payment delegation AuthSig scoped to pkp_sign for Bob.
  • Bob includes that AuthSig in capabilityAuthSigs and calls pkpSign.
  • The network charges Alice's ledger balance for that request.
import { createPaymentDelegationAuthSig } from '@lit-protocol/auth-helpers';

const paymentDelegationAuthSig = await createPaymentDelegationAuthSig({
  signer: sponsorAccount,
  signerAddress: sponsorAccount.address,
  delegateeAddresses: [userAccount.address],
  maxPrice: '50000000000000000',
  scopes: ['pkp_sign'],
  expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(),
  litClient,
});

const userAuthContext = await authManager.createEoaAuthContext({
  config: { account: userAccount },
  authConfig: {
    resources: [['pkp-signing', '*']],
    capabilityAuthSigs: [paymentDelegationAuthSig],
  },
  litClient,
});

await litClient.chain.ethereum.pkpSign({
  authContext: userAuthContext,
  pubKey: userPkpPublicKey,
  toSign: 'hello',
  userMaxPrice: 50000000000000000n,
});

TEST

Commands

NETWORK=naga pnpm test:target -- --runTestsByPath packages/e2e/src/tickets/payment-delegation-auth-sig.spec.ts
image

Copilot AI review requested due to automatic review settings January 7, 2026 05:02
@CLAassistant
Copy link

CLAassistant commented Jan 7, 2026

CLA assistant check
All committers have signed the CLA.

@Ansonhkg Ansonhkg changed the base branch from master to naga January 7, 2026 05:02
Copy link
Contributor

Copilot AI left a 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.

Copy link
Contributor

Copilot AI left a 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.

Comment on lines 70 to 77
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.`
);
}
Copy link

Copilot AI Jan 10, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +252
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 };
Copy link

Copilot AI Jan 10, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +73
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, '_')}`;
Copy link

Copilot AI Jan 10, 2026

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.

Copilot uses AI. Check for mistakes.
export type PaymentDelegationSigner =
| SignerLike
| {
signMessage: (message: string | any) => Promise<string>;
Copy link

Copilot AI Jan 10, 2026

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.

Suggested change
signMessage: (message: string | any) => Promise<string>;
signMessage: (message: string | Uint8Array) => Promise<string>;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants