Skip to content
Merged
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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.1] - 2026-05-07

### Added

- In signer mode, `PrivaraBlockchain.invoices.createAndSubmit(...)` now forwards `signer_address` to the backend so the backend can use that address as the FHE sender (`msg.sender` expected by the contract). Required for the on-chain transaction to succeed with the backend-caller pattern.
- `PrivaraBlockchain.getSignerAddress()` returns the configured signer's address (or `undefined` in legacy mode).

## [0.2.0] - 2026-05-07

### Added

- `BlockchainConfig.signer?: Account` — alternative signer mode for `PrivaraBlockchain` that sends a regular EOA transaction via viem `WalletClient` instead of a ZeroDev UserOperation. Designed for backend-caller patterns where the backend (e.g. KMS-backed account) creates invoices and pays gas itself, while the user's main wallet is passed as the encrypted owner via `wallet_id`.
- `createWalletClientFromSigner(config)` factory exported from `@privara/sdk/blockchain`.

### Changed

- `BlockchainConfig.serializedSessionKey` is now optional. The constructor of `PrivaraBlockchain` accepts exactly one of `signer` or `serializedSessionKey` and throws on conflict or when neither is provided.
- `PrivaraBlockchain.sendInvoiceTransaction` now branches on the configured mode. Calldata encoding and `Privara.transactions.report(...)` reporting are unchanged across modes.

### Backwards compatibility

- Legacy `serializedSessionKey` flow (ZeroDev kernel + paymaster) is unchanged. Existing consumers passing `{ serializedSessionKey }` continue to work without changes.

## [0.1.0] - 2026-03-07

### Added
Expand All @@ -23,5 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Idempotency key support
- Dual ESM/CJS build with TypeScript declarations

[Unreleased]: https://github.com/PrivaraXYZ/privara-sdk/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/PrivaraXYZ/privara-sdk/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/PrivaraXYZ/privara-sdk/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/PrivaraXYZ/privara-sdk/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/PrivaraXYZ/privara-sdk/releases/tag/v0.1.0
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@privara/sdk",
"version": "0.1.0",
"version": "0.2.1",
"description": "TypeScript SDK for the Privara API",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -47,6 +47,7 @@
"format": "biome format --write",
"playground": "node --env-file=playground/.env --import tsx playground/test-sdk.ts",
"playground:blockchain": "node --env-file=playground/.env --import tsx playground/test-blockchain.ts",
"playground:signer": "node --env-file=playground/.env --import tsx playground/test-signer-mode.ts",
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
Expand Down
90 changes: 90 additions & 0 deletions playground/test-signer-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createPublicClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { arbitrumSepolia } from 'viem/chains';
import { Privara, PrivaraApiError } from '../src/index.js';
import { PrivaraBlockchain } from '../src/blockchain/index.js';

const clientId = process.env.PRIVARA_CLIENT_ID;
const clientSecret = process.env.PRIVARA_CLIENT_SECRET;
const baseUrl = process.env.PRIVARA_BASE_URL || 'https://api.privara.io';
const signerPk = process.env.PRIVARA_SIGNER_PK as `0x${string}` | undefined;
const walletId = process.env.PRIVARA_TEST_WALLET_ID;
const rpcUrl = process.env.PRIVARA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc';

if (!clientId || !clientSecret) {
console.error('Set PRIVARA_CLIENT_ID and PRIVARA_CLIENT_SECRET in playground/.env');
process.exit(1);
}
if (!signerPk) {
console.error('Set PRIVARA_SIGNER_PK in playground/.env (0x-prefixed test private key, funded on Arbitrum Sepolia)');
process.exit(1);
}
if (!walletId) {
console.error('Set PRIVARA_TEST_WALLET_ID in playground/.env (encrypted-owner address — main passkey wallet)');
process.exit(1);
}

const signer = privateKeyToAccount(signerPk);
console.log(`Signer EOA: ${signer.address}`);
console.log(`RPC: ${rpcUrl}`);
console.log(`Encrypted owner (wallet_id): ${walletId}`);

const publicClient = createPublicClient({ chain: arbitrumSepolia, transport: http(rpcUrl) });

const balance = await publicClient.getBalance({ address: signer.address });
console.log(`Balance: ${balance} wei`);
if (balance === 0n) {
console.error('Signer has 0 ETH on Arbitrum Sepolia — fund it before running.');
process.exit(1);
}

const privara = new Privara({ clientId, clientSecret, baseUrl });
privara.addRequestInterceptor((req) => {
console.log(` -> ${req.method} ${req.path}`);
return req;
});

const blockchain = new PrivaraBlockchain(privara, { signer, rpcUrl });

console.log('\n=== createAndSubmit (signer mode) ===');
try {
const result = await blockchain.invoices.createAndSubmit({
from: 'sdk-test-signer@privara.dev',
due_date: '2026-12-31',
reference: 'SDK Signer Mode E2E',
amount: 1,
currency: { type: 'crypto', code: 'USDC' },
wallet_id: walletId,
});
console.log('Result:', JSON.stringify(result, null, 2));
console.log(`Arbiscan: https://sepolia.arbiscan.io/tx/${result.tx_hash}`);

console.log('\n--- Waiting for receipt ---');
const receipt = await publicClient.waitForTransactionReceipt({ hash: result.tx_hash });
console.log(`status: ${receipt.status}`);
console.log(`from: ${receipt.from}`);
console.log(`to: ${receipt.to}`);
console.log(`block: ${receipt.blockNumber}`);
console.log(`gas: ${receipt.gasUsed}`);

if (receipt.from.toLowerCase() !== signer.address.toLowerCase()) {
console.error(`FAIL: receipt.from (${receipt.from}) != signer (${signer.address})`);
process.exit(2);
}
if (receipt.status !== 'success') {
console.error(`FAIL: tx reverted`);
process.exit(2);
}
console.log('\nOK — signer mode works end-to-end on Arbitrum Sepolia.');
} catch (error) {
if (error instanceof PrivaraApiError) {
console.error(`API Error ${error.status}: ${error.title}`);
if (error.detail) console.error(`Detail: ${error.detail}`);
} else if (error instanceof Error) {
console.error(`Error: ${error.message}`);
if (error.stack) console.error(error.stack);
} else {
console.error('Error:', error);
}
process.exit(1);
}
1 change: 1 addition & 0 deletions src/blockchain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { encodeInvoiceCallData } from './calldata-encoder.js';
export { createKernelClientFromSessionKey } from './kernel-client-factory.js';
export { PrivaraBlockchain } from './privara-blockchain.js';
export type { BlockchainConfig, CreateAndSubmitResult, InvoiceTransactionResult } from './types.js';
export { createWalletClientFromSigner } from './wallet-client-factory.js';
4 changes: 4 additions & 0 deletions src/blockchain/kernel-client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ function buildBundlerUrl(projectId: string, chainId = ARBITRUM_SEPOLIA_CHAIN_ID)
}

export async function createKernelClientFromSessionKey(config: BlockchainConfig): Promise<KernelAccountClient> {
if (!config.serializedSessionKey) {
throw new Error('serializedSessionKey is required to create a kernel client');
}

const chain = arbitrumSepolia;
const rpcUrl = config.rpcUrl ?? DEFAULT_RPC_URL;
const projectId = config.zerodevProjectId ?? DEFAULT_ZERODEV_PROJECT_ID;
Expand Down
46 changes: 43 additions & 3 deletions src/blockchain/privara-blockchain.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import type { KernelAccountClient } from '@zerodev/sdk';
import type { Hex } from 'viem';
import type { Hex, WalletClient } from 'viem';
import { arbitrumSepolia } from 'viem/chains';
import type { Privara } from '../client.js';
import type { CreateInvoiceParams, CreateInvoiceZerodevResponse } from '../types/invoices.js';
import { encodeInvoiceCallData } from './calldata-encoder.js';
import { createKernelClientFromSessionKey } from './kernel-client-factory.js';
import type { BlockchainConfig, CreateAndSubmitResult } from './types.js';
import { createWalletClientFromSigner } from './wallet-client-factory.js';

export class PrivaraBlockchain {
readonly privara: Privara;
private readonly config: BlockchainConfig;
private readonly useSignerMode: boolean;
private kernelClientPromise: Promise<KernelAccountClient> | null = null;
private walletClient: WalletClient | null = null;

readonly invoices: BlockchainInvoices;

constructor(privara: Privara, config: BlockchainConfig) {
if (config.signer && config.serializedSessionKey) {
throw new Error('BlockchainConfig: provide either `signer` or `serializedSessionKey`, not both');
}
if (!config.signer && !config.serializedSessionKey) {
throw new Error('BlockchainConfig: one of `signer` or `serializedSessionKey` is required');
}

this.privara = privara;
this.config = config;
this.useSignerMode = Boolean(config.signer);
this.invoices = new BlockchainInvoices(this);
}

Expand All @@ -26,10 +38,36 @@ export class PrivaraBlockchain {
return this.kernelClientPromise;
}

private getWalletClient(): WalletClient {
if (!this.walletClient) {
this.walletClient = createWalletClientFromSigner(this.config);
}
return this.walletClient;
}

getSignerAddress(): string | undefined {
return this.config.signer?.address;
}

async sendInvoiceTransaction(invoice: CreateInvoiceZerodevResponse): Promise<Hex> {
const kernelClient = await this.getKernelClient();
const calldata = encodeInvoiceCallData(invoice);

if (this.useSignerMode) {
const walletClient = this.getWalletClient();
const account = walletClient.account;
if (!account) {
throw new Error('Wallet client has no account');
}
return walletClient.sendTransaction({
account,
chain: arbitrumSepolia,
to: invoice.contract_address as Hex,
data: calldata,
value: 0n,
});
}

const kernelClient = await this.getKernelClient();
const { account } = kernelClient;
if (!account) {
throw new Error('Kernel account not initialized');
Expand All @@ -55,7 +93,9 @@ class BlockchainInvoices {

async createAndSubmit(params: CreateInvoiceParams): Promise<CreateAndSubmitResult> {
const privara = this.blockchain.privara;
const invoice = (await privara.invoices.create(params)) as unknown as CreateInvoiceZerodevResponse;
const signerAddress = this.blockchain.getSignerAddress();
const invoiceParams = signerAddress ? { ...params, signer_address: signerAddress } : params;
const invoice = (await privara.invoices.create(invoiceParams)) as unknown as CreateInvoiceZerodevResponse;
const txHash = await this.blockchain.sendInvoiceTransaction(invoice);

await privara.transactions.report({
Expand Down
11 changes: 7 additions & 4 deletions src/blockchain/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { Hex } from 'viem';
import type { Account, Hex } from 'viem';

export interface BlockchainConfig {
serializedSessionKey: string;
zerodevProjectId?: string;
zerodevBundlerUrl?: string;
rpcUrl?: string;
chain?: 'arbitrum-sepolia';

serializedSessionKey?: string;
zerodevProjectId?: string;
zerodevBundlerUrl?: string;

signer?: Account;
}

export interface CreateAndSubmitResult {
Expand Down
16 changes: 16 additions & 0 deletions src/blockchain/wallet-client-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createWalletClient, http, type WalletClient } from 'viem';
import { arbitrumSepolia } from 'viem/chains';
import type { BlockchainConfig } from './types.js';

const DEFAULT_RPC_URL = 'https://sepolia-rollup.arbitrum.io/rpc';

export function createWalletClientFromSigner(config: BlockchainConfig): WalletClient {
if (!config.signer) {
throw new Error('signer is required to create a wallet client');
}
return createWalletClient({
account: config.signer,
chain: arbitrumSepolia,
transport: http(config.rpcUrl ?? DEFAULT_RPC_URL),
});
}
1 change: 1 addition & 0 deletions src/types/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface CreateInvoiceParams {
wallet_id: string;
provider_id?: 'CIRCLE';
metadata?: string;
signer_address?: string;
}

export interface CreateInvoiceResponse {
Expand Down
Loading
Loading