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
38 changes: 32 additions & 6 deletions src/tempo/client/ChannelOps.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hex } from 'ox'
import { type Address, createClient } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { Addresses } from 'viem/tempo'
import { Account as TempoAccount, Addresses } from 'viem/tempo'
import { beforeAll, describe, expect, test } from 'vp/test'
import { nodeEnv } from '~test/config.js'
import { deployEscrow, openChannel } from '~test/tempo/session.js'
Expand Down Expand Up @@ -140,6 +140,28 @@ describe('createVoucherPayload', () => {
)
expect(valid).toBe(true)
})

test('requires voucherSigner for access-key accounts', async () => {
const accessKey = TempoAccount.fromSecp256k1(
'0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d',
{ access: localAccount },
)
const accessKeyClient = createClient({
account: accessKey,
transport: http('http://127.0.0.1'),
})

await expect(
createVoucherPayload(
accessKeyClient,
accessKey,
channelId,
5_000_000n,
escrowContract,
chainId,
),
).rejects.toThrow('Access-key session accounts require `voucherSigner`')
})
})

describe('createClosePayload', () => {
Expand Down Expand Up @@ -228,19 +250,21 @@ describe.runIf(isLocalnet)('createOpenPayload', () => {
chainId: chain.id,
})

expect((result.payload as any).authorizedSigner).toBe(payer.address)
expect(result.payload.action).toBe('open')
if (result.payload.action !== 'open') throw new Error('unexpected action')
expect(result.payload.authorizedSigner).toBe(payer.address)
})

test('uses custom authorizedSigner when provided', async () => {
const customSigner = accounts[5].address
test('derives authorizedSigner from voucherSigner when provided', async () => {
const voucherSigner = accounts[5]
const payerClient = createClient({
account: payer,
chain,
transport: http(),
})

const result = await createOpenPayload(payerClient, payer, {
authorizedSigner: customSigner,
voucherSigner,
escrowContract: escrow,
payee,
currency,
Expand All @@ -249,7 +273,9 @@ describe.runIf(isLocalnet)('createOpenPayload', () => {
chainId: chain.id,
})

expect((result.payload as any).authorizedSigner).toBe(customSigner)
expect(result.payload.action).toBe('open')
if (result.payload.action !== 'open') throw new Error('unexpected action')
expect(result.payload.authorizedSigner).toBe(voucherSigner.address)
})
})

Expand Down
30 changes: 23 additions & 7 deletions src/tempo/client/ChannelOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Abis } from 'viem/tempo'

import type { Challenge } from '../../Challenge.js'
import * as Credential from '../../Credential.js'
import { getAccountSignerAddress, isAccessKeyAccount } from '../internal/account.js'
import * as defaults from '../internal/defaults.js'
import { escrowAbi, getOnChainChannel } from '../session/Chain.js'
import * as Channel from '../session/Channel.js'
Expand All @@ -33,6 +34,18 @@ export type ChannelEntry = {
opened: boolean
}

function resolveVoucherSigner(
account: viem_Account,
voucherSigner?: viem_Account | undefined,
): viem_Account {
if (voucherSigner) return voucherSigner
if (isAccessKeyAccount(account))
throw new Error(
'Access-key session accounts require `voucherSigner` for voucher signatures. Pass the direct access-key signer without `access` as `voucherSigner`.',
)
return account
}

export function resolveChainId(challenge: Challenge): number {
const md = challenge.request.methodDetails as { chainId?: number } | undefined
return md?.chainId ?? 0
Expand Down Expand Up @@ -76,15 +89,16 @@ export async function createVoucherPayload(
cumulativeAmount: bigint,
escrowContract: Address,
chainId: number,
authorizedSigner?: Address | undefined,
voucherSigner?: viem_Account | undefined,
): Promise<SessionCredentialPayload> {
const signer = resolveVoucherSigner(account, voucherSigner)
const signature = await signVoucher(
client,
account,
{ channelId, cumulativeAmount },
escrowContract,
chainId,
authorizedSigner,
signer,
)
return {
action: 'voucher',
Expand All @@ -101,15 +115,16 @@ export async function createClosePayload(
cumulativeAmount: bigint,
escrowContract: Address,
chainId: number,
authorizedSigner?: Address | undefined,
voucherSigner?: viem_Account | undefined,
): Promise<SessionCredentialPayload> {
const signer = resolveVoucherSigner(account, voucherSigner)
const signature = await signVoucher(
client,
account,
{ channelId, cumulativeAmount },
escrowContract,
chainId,
authorizedSigner,
signer,
)
return {
action: 'close',
Expand All @@ -123,7 +138,7 @@ export async function createOpenPayload(
client: viem_Client,
account: viem_Account,
options: {
authorizedSigner?: Address | undefined
voucherSigner?: viem_Account | undefined
escrowContract: Address
payee: Address
currency: Address
Expand All @@ -134,7 +149,8 @@ export async function createOpenPayload(
},
): Promise<{ entry: ChannelEntry; payload: SessionCredentialPayload }> {
const { escrowContract, payee, currency, deposit, initialAmount, chainId, feePayer } = options
const authorizedSigner = options.authorizedSigner ?? account.address
const voucherSigner = resolveVoucherSigner(account, options.voucherSigner)
const authorizedSigner = getAccountSignerAddress(voucherSigner)

const salt = Hex.random(32)
const channelId = Channel.computeId({
Expand Down Expand Up @@ -176,7 +192,7 @@ export async function createOpenPayload(
{ channelId, cumulativeAmount: initialAmount },
escrowContract,
chainId,
options.authorizedSigner,
voucherSigner,
)

return {
Expand Down
120 changes: 119 additions & 1 deletion src/tempo/client/Session.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SignatureEnvelope } from 'ox/tempo'
import { type Address, createClient, decodeFunctionData, erc20Abi, type Hex, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { Addresses, Transaction } from 'viem/tempo'
import { Account as TempoAccount, Addresses, Transaction, WebCryptoP256 } from 'viem/tempo'
import { beforeAll, describe, expect, test } from 'vp/test'
import { nodeEnv } from '~test/config.js'
import { deployEscrow, openChannel } from '~test/tempo/session.js'
Expand All @@ -13,6 +14,7 @@ import * as Credential from '../../Credential.js'
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
import { escrowAbi } from '../session/Chain.js'
import type { SessionCredentialPayload } from '../session/Types.js'
import { verifyVoucher } from '../session/Voucher.js'
import { session } from './Session.js'

function deserializePayload(result: string) {
Expand Down Expand Up @@ -184,6 +186,122 @@ describe('session (pure)', () => {
expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
})

test('manual open signs voucher with P256 voucher signer', async () => {
const keyPair = await WebCryptoP256.createKeyPair()
const voucherSigner = TempoAccount.fromWebCryptoP256(keyPair)
const method = session({
getClient: () => pureClient,
account: pureAccount,
voucherSigner,
})

const result = await method.createCredential({
challenge: makeChallenge(),
context: {
action: 'open',
channelId,
cumulativeAmount: '5',
transaction: '0xdeadbeef',
},
})

const cred = deserializePayload(result)
expect(cred.payload.action).toBe('open')
if (cred.payload.action !== 'open') throw new Error('unexpected action')
expect(cred.payload.authorizedSigner).toBe(voucherSigner.address)

const envelope = SignatureEnvelope.from(cred.payload.signature)
expect(envelope.type).toBe('p256')
expect(cred.payload.signature.length).toBe(262)

const isValid = await verifyVoucher(
escrowAddress,
42431,
{
channelId,
cumulativeAmount: 5_000_000n,
signature: cred.payload.signature,
},
voucherSigner.address,
)
expect(isValid).toBe(false)
expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
})

test('manual open requires voucherSigner for access-key accounts', async () => {
const accessKey = TempoAccount.fromSecp256k1(
'0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d',
{ access: pureAccount },
)
const method = session({
getClient: () => pureClient,
account: accessKey,
})

await expect(
method.createCredential({
challenge: makeChallenge(),
context: {
action: 'open',
channelId,
cumulativeAmount: '5',
transaction: '0xdeadbeef',
},
}),
).rejects.toThrow('Access-key session accounts require `voucherSigner`')
})

test('manual open signs access-key vouchers with direct voucher signer', async () => {
const privateKey = '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d'
const accessKey = TempoAccount.fromSecp256k1(privateKey, { access: pureAccount })
const voucherSigner = TempoAccount.fromSecp256k1(privateKey)
const accessKeyClient = createClient({
account: accessKey,
transport: http('http://127.0.0.1'),
})
const method = session({
getClient: () => accessKeyClient,
account: accessKey,
voucherSigner,
})

const result = await method.createCredential({
challenge: makeChallenge(),
context: {
action: 'open',
channelId,
cumulativeAmount: '5',
transaction: '0xdeadbeef',
},
})

const cred = deserializePayload(result)
expect(cred.payload.action).toBe('open')
if (cred.payload.action !== 'open') throw new Error('unexpected action')
expect(cred.payload.authorizedSigner).toBeDefined()
if (!cred.payload.authorizedSigner) throw new Error('missing authorizedSigner')
expect(cred.payload.authorizedSigner.toLowerCase()).toBe(
accessKey.accessKeyAddress.toLowerCase(),
)

const envelope = SignatureEnvelope.from(cred.payload.signature)
expect(envelope.type).toBe('secp256k1')
expect(cred.payload.signature.length).toBe(132)

const isValid = await verifyVoucher(
escrowAddress,
42431,
{
channelId,
cumulativeAmount: 5_000_000n,
signature: cred.payload.signature,
},
accessKey.accessKeyAddress,
)
expect(isValid).toBe(true)
expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
})

test('manual close produces valid credential', async () => {
const method = session({ getClient: () => pureClient, account: pureAccount })

Expand Down
Loading