Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clever-peas-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lit-protocol/auth-helpers': patch
'@lit-protocol/types': patch
'@lit-protocol/e2e': patch
---

add payment delegation auth sig
5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ testem.log
.DS_Store
Thumbs.db

.vscode

# Next.js
.next
lerna-debug.log
Expand Down Expand Up @@ -97,9 +95,8 @@ alice-auth-manager-data

.plans
.e2e
alice-auth-manager-data
!/packages/contracts/dist/
!/packages/contracts/dist/dev
.secret
.codex
.agents
.vscode
42 changes: 42 additions & 0 deletions docs/sdk/getting-started/payment-manager-setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,48 @@ const response = await litClient.decrypt({
});
```

## 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.

```typescript
import { createPaymentDelegationAuthSig } from '@lit-protocol/auth-helpers';

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

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,
});
```

## Auth Service API Endpoints

Leverage the hosted Auth Service to manage delegation without exposing private keys in your application. Full request/response details live in the [Auth Services setup guide](/sdk/getting-started/auth-services#payment-delegation-apis). In practice you will:
Expand Down
5 changes: 5 additions & 0 deletions jest.e2e.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const config: Config = {

// Resolve monorepo packages to sources
moduleNameMapper: {
'^@lit-protocol/contracts/custom-network-signatures$':
'<rootDir>/packages/contracts/dist/custom-network-signatures.cjs',
'^@lit-protocol/contracts$': '<rootDir>/packages/contracts/dist/index.cjs',
'^@lit-protocol/contracts/(.*)$':
'<rootDir>/packages/contracts/dist/$1.cjs',
// Local packages
[`^@lit-protocol/(${localPackages.join('|')})/lib/(.*)$`]:
'<rootDir>/packages/$1/src/lib/$2',
Expand Down
1 change: 1 addition & 0 deletions packages/auth-helpers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './lib/auth-config-builder';
export * from './lib/generate-auth-sig';
export * from './lib/models';
export * from './lib/payment-delegation';
export * from './lib/recap/recap-session-capability-object';
export * from './lib/recap/resource-builder';
export * from './lib/recap/utils';
Expand Down
252 changes: 252 additions & 0 deletions packages/auth-helpers/src/lib/payment-delegation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 };
Comment on lines +1 to +252
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.
27 changes: 13 additions & 14 deletions packages/e2e/src/helper/createEnvVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,28 +52,27 @@ export function createEnvVars(): EnvVars {
const selectedNetwork = network.includes('local') ? 'local' : 'live';

// 2. Get private key
let privateKey: `0x${string}`;
let privateKey: `0x${string}` | undefined;
if (network.includes('local')) {
Object.assign(testEnv.local, { type: 'local' });
privateKey = process.env[testEnv.local.key]!! as `0x${string}`;
privateKey = process.env[testEnv.local.key] as `0x${string}`;
} else {
Object.assign(testEnv.live, { type: 'live' });
const liveKey =
network === 'naga' && process.env['LIVE_MASTER_ACCOUNT_NAGA']
? 'LIVE_MASTER_ACCOUNT_NAGA'
: testEnv.live.key;
privateKey = process.env[liveKey]!! as `0x${string}`;
const legacyKey = testEnv.live.key;
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, '_')}`;
Comment on lines +62 to +73
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.
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.`
);
}
Comment on lines 70 to 77
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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { registerPaymentDelegationAuthSigTicketSuite } from './payment-delegation-auth-sig.suite';

registerPaymentDelegationAuthSigTicketSuite();
Loading
Loading