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
5 changes: 5 additions & 0 deletions .changeset/tempo-session-credential-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added non-mutating credential validation and broadcast hooks to the Tempo session server method.
142 changes: 131 additions & 11 deletions src/tempo/session/precompile/Chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,55 @@ async function simulateTempoTransaction(client: Client, request: unknown) {
await call(client, request as never)
}

/** Simulates an unsponsored client-signed session transaction without broadcasting it. */
export async function simulateCredentialTransaction(parameters: {
/** viem client used for the read-only execution simulation. */
client: TransactionClient
/** Fee-payer configuration for the eventual broadcast. Sponsored transactions are simulated after completion. */
feePayer?: Account | true | undefined
/** Parsed client-signed Tempo transaction. */
transaction: ReturnType<(typeof Transaction)['deserialize']>
}): Promise<void> {
if (parameters.feePayer) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid sponsored credentials during validation

When a session is configured with a local fee payer or hosted fee payer, this early return makes the validation hook accept sponsored open/topUp credentials without checking the sponsorship constraints that broadcast later enforces (for example an explicitly disallowed feeToken, expired validBefore, or gas values outside feePayerPolicy). A caller using mppx.validateCredential() can therefore receive a successful validation for a credential that the same method rejects during broadcast, so sponsored validation should run the non-mutating fee-payer policy/fee-token checks instead of unconditionally skipping them.

Useful? React with 👍 / 👎.

await simulateTempoTransaction(
parameters.client,
FeePayer.simulationTransaction(parameters.transaction, { feePayer: false }),
)
}

function validateCredentialSponsorship(parameters: {
allowedFeeTokens: readonly Address[]
challengeExpires?: string | undefined
chainId: number
details: Record<string, string>
feePayer?: Account | true | undefined
feePayerPolicy?: Partial<FeePayer.Policy> | undefined
transaction: ReturnType<(typeof Transaction)['deserialize']>
}) {
const {
allowedFeeTokens,
challengeExpires,
chainId,
details,
feePayer,
feePayerPolicy,
transaction,
} = parameters
if (!feePayer || feePayer === true) return
FeePayer.prepareSponsoredTransaction({
account: feePayer,
allowedFeeTokens,
challengeExpires,
chainId,
details,
policy: feePayerPolicy,
transaction: {
...transaction,
...(allowedFeeTokens[0] ? { feeToken: transaction.feeToken ?? allowedFeeTokens[0] } : {}),
},
})
}

async function signTempoTransaction(client: Client, transaction: unknown): Promise<Hex> {
return (await signTransaction(client, transaction as never)) as Hex
}
Expand Down Expand Up @@ -874,10 +923,25 @@ export type BroadcastOpenTransactionParameters = {
serializedTransaction: Hex
}

/** Broadcast and validate a client-signed TIP-1034 open transaction. */
export async function broadcastOpenTransaction(
parameters: BroadcastOpenTransactionParameters,
): Promise<BroadcastOpenTransactionResult> {
/** Inputs for validating a client-signed TIP-1034 open transaction without broadcasting it. */
export type ValidateOpenCredentialTransactionParameters = Omit<
BroadcastOpenTransactionParameters,
'beforeBroadcast' | 'client'
>

/** Validated fields recovered from a client-signed TIP-1034 open transaction. */
export type ValidatedOpenCredentialTransaction = Omit<
BroadcastOpenTransactionResult,
'state' | 'txHash'
> & {
/** Parsed client-signed transaction, reused by simulation and broadcast. */
transaction: ReturnType<(typeof Transaction)['deserialize']>
}

/** Validates the immutable fields of a client-signed TIP-1034 open transaction. */
export function validateOpenCredentialTransaction(
parameters: ValidateOpenCredentialTransactionParameters,
): ValidatedOpenCredentialTransaction {
const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({
escrowContract: parameters.escrowContract,
feePayer: parameters.feePayer,
Expand Down Expand Up @@ -909,6 +973,19 @@ export async function broadcastOpenTransaction(
channelId: parameters.expectedChannelId,
})
if (parameters.feePayer) assertSenderSigned(transaction)
validateCredentialSponsorship({
allowedFeeTokens: [parameters.expectedCurrency],
challengeExpires: parameters.challengeExpires,
chainId: parameters.chainId,
details: {
channelId: parameters.expectedChannelId,
currency: parameters.expectedCurrency,
recipient: parameters.expectedPayee,
},
feePayer: parameters.feePayer,
feePayerPolicy: parameters.feePayerPolicy,
transaction,
})
const expiringNonceHash = ChannelUtils.computeExpiringNonceHash(
ChannelUtils.transactionForExpiringNonceHash({
feePayer: parameters.feePayer,
Expand All @@ -920,10 +997,19 @@ export async function broadcastOpenTransaction(
throw new VerificationFailedError({
reason: 'credential expiringNonceHash does not match transaction',
})
return { descriptor, expiringNonceHash, openDeposit: open.deposit, transaction }
}

/** Broadcast and validate a client-signed TIP-1034 open transaction. */
export async function broadcastOpenTransaction(
parameters: BroadcastOpenTransactionParameters,
): Promise<BroadcastOpenTransactionResult> {
const { descriptor, expiringNonceHash, openDeposit, transaction } =
validateOpenCredentialTransaction(parameters)
await parameters.beforeBroadcast?.({
descriptor,
expiringNonceHash,
openDeposit: open.deposit,
openDeposit,
})
const receipt = await sendCredentialTransaction({
challengeExpires: parameters.challengeExpires,
Expand Down Expand Up @@ -952,7 +1038,7 @@ export async function broadcastOpenTransaction(
emittedExpiringNonceHash: opened.expiringNonceHash,
escrow: parameters.escrowContract,
expectedChannelId: parameters.expectedChannelId,
openDeposit: open.deposit,
openDeposit,
})
const chainChannel = await readbackWithRetry(() =>
getChannel(parameters.client, descriptor, parameters.escrowContract, receipt.blockNumber),
Expand All @@ -964,7 +1050,7 @@ export async function broadcastOpenTransaction(
descriptor,
state,
expiringNonceHash: opened.expiringNonceHash,
openDeposit: open.deposit,
openDeposit,
}
}

Expand Down Expand Up @@ -1004,10 +1090,22 @@ export type BroadcastTopUpTransactionParameters = {
serializedTransaction: Hex
}

/** Broadcast and validate a client-signed TIP-1034 top-up transaction. */
export async function broadcastTopUpTransaction(
parameters: BroadcastTopUpTransactionParameters,
): Promise<BroadcastTopUpTransactionResult> {
/** Inputs for validating a client-signed TIP-1034 top-up transaction without broadcasting it. */
export type ValidateTopUpCredentialTransactionParameters = Omit<
BroadcastTopUpTransactionParameters,
'client'
>

/** Validated fields recovered from a client-signed TIP-1034 top-up transaction. */
export type ValidatedTopUpCredentialTransaction = {
/** Parsed client-signed transaction, reused by simulation and broadcast. */
transaction: ReturnType<(typeof Transaction)['deserialize']>
}

/** Validates the immutable fields of a client-signed TIP-1034 top-up transaction. */
export function validateTopUpCredentialTransaction(
parameters: ValidateTopUpCredentialTransactionParameters,
): ValidatedTopUpCredentialTransaction {
const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({
escrowContract: parameters.escrowContract,
feePayer: parameters.feePayer,
Expand All @@ -1027,6 +1125,28 @@ export async function broadcastTopUpTransaction(
label: 'topUp',
prefixCalls,
})
if (parameters.feePayer) assertSenderSigned(transaction)
validateCredentialSponsorship({
allowedFeeTokens: [parameters.expectedCurrency],
challengeExpires: parameters.challengeExpires,
chainId: parameters.chainId,
details: {
additionalDeposit: parameters.additionalDeposit.toString(),
channelId: parameters.expectedChannelId,
currency: parameters.expectedCurrency,
},
feePayer: parameters.feePayer,
feePayerPolicy: parameters.feePayerPolicy,
transaction,
})
return { transaction }
}

/** Broadcast and validate a client-signed TIP-1034 top-up transaction. */
export async function broadcastTopUpTransaction(
parameters: BroadcastTopUpTransactionParameters,
): Promise<BroadcastTopUpTransactionResult> {
const { transaction } = validateTopUpCredentialTransaction(parameters)
const receipt = await sendCredentialTransaction({
challengeExpires: parameters.challengeExpires,
chainId: parameters.chainId,
Expand Down
45 changes: 33 additions & 12 deletions src/tempo/session/server/ChannelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,18 @@ export type VerifyAndAcceptVoucherParameters = {
voucher: SignedVoucher
}

/** Inputs for validating a cumulative voucher without recording it. */
export type ValidateVoucherParameters = Omit<
VerifyAndAcceptVoucherParameters,
'challenge' | 'store'
>

/** Result of validating a cumulative voucher against persisted channel state. */
export type VoucherValidation = {
/** Whether the voucher was already accepted at the same cumulative amount. */
alreadyAccepted: boolean
}

/** Channel backend-specific fields. */
export type BackendState = CompatibilityBackendState | PrecompileBackendState

Expand Down Expand Up @@ -520,12 +532,11 @@ export async function loadPrecompileChannel(
return channel
}

/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */
export async function verifyAndAcceptVoucher(
parameters: VerifyAndAcceptVoucherParameters,
): Promise<SessionReceipt> {
const { store, minVoucherDelta, challenge, channel, voucher, channelState, methodDetails } =
parameters
/** Verifies a cumulative voucher without changing persisted channel state. */
export async function validateVoucher(
parameters: ValidateVoucherParameters,
): Promise<VoucherValidation> {
const { minVoucherDelta, channel, voucher, channelState, methodDetails } = parameters

validateChannelState(channelState)
if (voucher.cumulativeAmount > channelState.deposit)
Expand All @@ -546,19 +557,29 @@ export async function verifyAndAcceptVoucher(
throw new VerificationFailedError({
reason: 'voucher cumulativeAmount is below on-chain settled amount',
})
if (voucher.cumulativeAmount === channel.highestVoucherAmount)
if (voucher.cumulativeAmount === channel.highestVoucherAmount) return { alreadyAccepted: true }
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
if (delta < minVoucherDelta)
throw new DeltaTooSmallError({
reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
})
return { alreadyAccepted: false }
}

/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */
export async function verifyAndAcceptVoucher(
parameters: VerifyAndAcceptVoucherParameters,
): Promise<SessionReceipt> {
const { store, challenge, channel, voucher, channelState } = parameters
const validation = await validateVoucher(parameters)
if (validation.alreadyAccepted)
return createSessionReceipt({
challengeId: challenge.id,
channelId: voucher.channelId,
acceptedCumulative: channel.highestVoucherAmount,
spent: channel.spent,
units: channel.units,
})
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
if (delta < minVoucherDelta)
throw new DeltaTooSmallError({
reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
})
const updated = await store.updateChannel(voucher.channelId, (current) =>
acceptVoucherStateUpdate({ channelState, current, voucher }),
)
Expand Down
Loading
Loading