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
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build workspace SDK (consumed by agent + tests)
run: pnpm -r --filter @sipher/sdk run build

- name: Typecheck
run: pnpm typecheck

Expand Down
1,323 changes: 1,323 additions & 0 deletions docs/superpowers/plans/2026-05-17-claim-phase-2-path-a.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/agent/src/integrations/torque/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Users who want zero attribution leakage should set `TORQUE_GROWTH_ENABLED=false`
|---|---|---|---|
| `send` (chat-driven) | `sipher_private_send_completed` | Yes (since sipher#262) | Fires after SignTxCard callback `/api/tool-signing/:flagId/confirm` |
| `swap` (chat-driven) | `sipher_private_swap_completed` | Yes (since sipher#262) | Same flow as send; includes `amount_lamports` + `asset` |
| `claim` (chat-driven) | `sipher_private_claim_completed` | Partial | Uses input deposit-tx-signature as the emission key. Proper fix tracked in the claim Phase 2 follow-up. |
| `claim` (chat-driven) | `sipher_private_claim_completed` | Yes (Path A) | Uses the CLAIM tx signature (`result.signature`) as the emission key, distinct from the input deposit-tx-signature (`result.depositTxSignature`). |
| `drip`, `splitSend`, `sweep`, `consolidate`, `recurring`, `scheduleSend` | `sipher_private_drip_completed`, `sipher_private_split_send_completed`, etc. | No | Scheduled-op broadcasts not yet wired. Needs wallet-delegation or pre-signed-batch design — separate follow-up. |
| `deposit`, `refund` | — | No | Routed through `DepositView` / `WithdrawView` dedicated UIs, not the chat path. |

Expand Down
176 changes: 176 additions & 0 deletions packages/agent/src/tools/claim-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Buffer } from 'node:buffer'
import {
PublicKey,
type Connection,
type ParsedInstruction,
type PartiallyDecodedInstruction,
} from '@solana/web3.js'

export class StealthContextError extends Error {
constructor(
message: string,
public readonly code:
| 'deposit_not_found'
| 'no_withdraw_event'
| 'no_token_transfer'
| 'stealth_ata_mismatch',
) {
super(message)
this.name = 'StealthContextError'
}
}

export interface StealthContext {
/** Base58-encoded stealth pubkey (must equal the stealth ATA's owner). */
stealthAddress: string
/** Base58-encoded ephemeral pubkey (32 bytes, 0x00 prefix already stripped). */
ephemeralPublicKey: string
/** Base58-encoded SPL token mint of the stealth ATA. */
mint: string
}

const PROGRAM_DATA_PREFIX = 'Program data: '
/**
* Minimum VaultWithdrawEvent bytes:
* 8 (disc) + 32 (depositor) + 32 (stealth) + 33 (commitment) + 33 (ephemeral)
* + 32 (vk_hash) + 8 (amount) + 8 (fee) + 8 (ts) = 194
*/
const WITHDRAW_EVENT_MIN_BYTES = 194
const SPL_TOKEN_PROGRAMS = new Set(['spl-token', 'spl-token-2022'])

function isParsedInstruction(
ix: ParsedInstruction | PartiallyDecodedInstruction,
): ix is ParsedInstruction {
return 'program' in ix && 'parsed' in ix
}

/**
* Resolves a deposit transaction signature into the cryptographic context
* needed by `claimStealthPayment`. Two RPC calls: one `getParsedTransaction`
* (to read the VaultWithdrawEvent log + SPL transfer instruction) and one
* `getParsedAccountInfo` (to sanity-check the stealth ATA's owner).
*/
export async function resolveStealthContext(
connection: Connection,
depositTxSignature: string,
): Promise<StealthContext> {
const tx = await connection.getParsedTransaction(depositTxSignature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
})

if (!tx) {
throw new StealthContextError(
`Deposit transaction ${depositTxSignature.slice(0, 12)}... not found on chain`,
'deposit_not_found',
)
}

const event = parseWithdrawEventFromLogs(tx.meta?.logMessages ?? [])
if (!event) {
throw new StealthContextError(
`No VaultWithdrawEvent in deposit ${depositTxSignature.slice(0, 12)}... — is this a sipher private send?`,
'no_withdraw_event',
)
}

const topLevel = tx.transaction.message.instructions
const inner = (tx.meta?.innerInstructions ?? []).flatMap((g) => g.instructions)
const allInstructions: ReadonlyArray<ParsedInstruction | PartiallyDecodedInstruction> = [
...topLevel,
...inner,
]

const tokenIx = allInstructions.filter(isParsedInstruction).find((ix) => {
if (!SPL_TOKEN_PROGRAMS.has(ix.program)) return false
const parsed = ix.parsed as { type?: string; info?: unknown } | string | null
if (typeof parsed !== 'object' || parsed === null) return false
if (parsed.type !== 'transferChecked') return false
if (typeof parsed.info !== 'object' || parsed.info === null) return false
return true
})
if (!tokenIx) {
throw new StealthContextError(
`No SPL transferChecked instruction in deposit ${depositTxSignature.slice(0, 12)}...`,
'no_token_transfer',
)
}

const tokenInfo = (tokenIx.parsed as { info: { destination?: unknown; mint?: unknown } }).info
const stealthATA = typeof tokenInfo.destination === 'string' ? tokenInfo.destination : null
const mint = typeof tokenInfo.mint === 'string' ? tokenInfo.mint : null
if (!stealthATA || !mint) {
throw new StealthContextError(
`SPL transferChecked missing destination or mint in deposit ${depositTxSignature.slice(0, 12)}...`,
'no_token_transfer',
)
}

const ataInfo = await connection.getParsedAccountInfo(new PublicKey(stealthATA))
const ataData = ataInfo?.value?.data
const ataOwner =
ataData && typeof ataData === 'object' && 'parsed' in ataData
? ((ataData.parsed as { info?: { owner?: unknown } } | undefined)?.info?.owner ?? null)
: null
if (typeof ataOwner !== 'string') {
throw new StealthContextError(
`Stealth ATA ${stealthATA.slice(0, 12)}... is unreadable or not a parsed token account`,
'stealth_ata_mismatch',
)
}
if (ataOwner !== event.stealthAddress) {
throw new StealthContextError(
`Stealth ATA owner ${ataOwner.slice(0, 12)}... does not match VaultWithdrawEvent stealth_recipient ${event.stealthAddress.slice(0, 12)}...`,
'stealth_ata_mismatch',
)
}

return {
stealthAddress: event.stealthAddress,
ephemeralPublicKey: event.ephemeralPublicKey,
mint,
}
}

interface WithdrawEvent {
stealthAddress: string
ephemeralPublicKey: string
}

/**
* Parses the first decode-able VaultWithdrawEvent out of `Program data: <b64>`
* log lines. Mirrors `parseWithdrawEvent` in packages/sdk/src/privacy.ts:413-454
* (no discriminator check — matches sipher's scan behavior; relies on the
* 194-byte length floor and the downstream ATA-owner equality check to
* filter spurious decodes).
*/
function parseWithdrawEventFromLogs(logs: string[]): WithdrawEvent | null {
for (const log of logs) {
if (!log.startsWith(PROGRAM_DATA_PREFIX)) continue
const b64 = log.slice(PROGRAM_DATA_PREFIX.length).trim()
if (!b64) continue
let data: Buffer
try {
data = Buffer.from(b64, 'base64')
} catch {
continue
}
if (data.length < WITHDRAW_EVENT_MIN_BYTES) continue
try {
// Layout (after 8-byte discriminator): depositor[32] | stealth[32]
// | commitment[33] | ephemeral[33] | vk_hash[32] | amount[8] | fee[8] | ts[8]
const stealthBytes = data.subarray(40, 72)
const stealthAddress = new PublicKey(stealthBytes).toBase58()
// Ephemeral is 33 bytes (0x00 prefix + 32-byte ed25519); strip prefix.
const ephRaw = data[105] === 0x00 ? data.subarray(106, 138) : data.subarray(105, 137)
if (ephRaw.length !== 32) continue
// Skip pre-integration placeholder events with zero-filled ephemeral.
if (ephRaw.every((b) => b === 0)) continue
const ephemeralPublicKey = new PublicKey(ephRaw).toBase58()
return { stealthAddress, ephemeralPublicKey }
} catch {
continue // not a VaultWithdrawEvent layout; try next log
}
}
return null
}
134 changes: 97 additions & 37 deletions packages/agent/src/tools/claim.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,58 @@
import { PublicKey } from '@solana/web3.js'
import { claimStealthPayment, type SolanaClaimResult } from '@sip-protocol/sdk'
import { createConnection } from '@sipher/sdk'
import { loadNetworkConfig } from '../config/network.js'
import { resolveStealthContext, StealthContextError } from './claim-helpers.js'
import type { AnthropicTool } from '../pi/tool-adapter.js'

// ─────────────────────────────────────────────────────────────────────────────
// Claim tool — Claim a received stealth payment
//
// NOTE: Claim uses sip_privacy program's claim_transfer instruction, not
// sipher_vault. The stealth private key derivation requires the full
// @sip-protocol/sdk ECDH flow. This is scaffolded for Phase 1 — the tool
// validates inputs, derives the stealth key concept, and returns the
// prepared shape. Phase 2 will wire to the real claim_transfer instruction.
// Delegates ECDH derivation + SPL transfer + broadcast to @sip-protocol/sdk's
// claimStealthPayment. Stealth context (stealth address, ephemeral pubkey,
// mint) is resolved from the deposit tx via resolveStealthContext (helper).
// Returns the claim tx signature for honest Torque attribution.
// ─────────────────────────────────────────────────────────────────────────────

export interface ClaimParams {
txSignature: string
viewingKey: string
spendingKey: string
destinationWallet?: string
/** Destination wallet (base58) to receive claimed tokens. */
destinationWallet: string
/**
* Optional SPL token mint (base58). If omitted, the mint is resolved from
* the deposit transaction. Provide explicitly when you already know it
* (e.g. from a prior scan result) to skip the on-chain lookup.
*/
mint?: string
}

export interface ClaimToolResult {
action: 'claim'
txSignature: string
status: 'awaiting_signature'
status: 'confirmed'
/** The input deposit-tx signature (for traceability). */
depositTxSignature: string
/** The CLAIM tx signature — growth-hook reads this as the Torque attribution key. */
signature: string
/** Base58 destination wallet that received the funds. */
destinationWallet: string
/** Claimed amount in the token's smallest unit, stringified to avoid BigInt JSON issues. */
amount: string
/** Base58 SPL token mint. */
mint: string
/** Explorer URL for the claim transaction. */
explorerUrl: string
/** Human-readable summary for the chat UX. */
message: string
/** Base64-serialized unsigned transaction (null until Phase 2 integration) */
serializedTx: string | null
details: {
stealthKeyDerived: boolean
destinationWallet: string | null
note: string
}
}

export const claimTool: AnthropicTool = {
name: 'claim',
description:
'Claim a received stealth payment found by the scan tool. ' +
'Derives the stealth private key from your viewing+spending keys and builds a claim transaction. ' +
'The claimed tokens are sent to your destination wallet.',
'Derives the stealth private key from your viewing+spending keys, ' +
'transfers the tokens to your destination wallet, and returns the claim tx signature.',
input_schema: {
type: 'object' as const,
properties: {
Expand All @@ -46,50 +62,94 @@ export const claimTool: AnthropicTool = {
},
viewingKey: {
type: 'string',
description: 'Your viewing private key (hex or base58)',
description: 'Your viewing private key (hex, with or without 0x prefix)',
},
spendingKey: {
type: 'string',
description: 'Your spending private key (hex or base58). Used to derive the stealth key.',
description: 'Your spending private key (hex, with or without 0x prefix). Used to derive the stealth key.',
},
destinationWallet: {
type: 'string',
description: 'Wallet address (base58) to receive claimed tokens. Defaults to the spending pubkey.',
description: 'Wallet address (base58) to receive claimed tokens. Required (auto-derive from spending pubkey is a planned follow-up).',
},
mint: {
type: 'string',
description:
'Optional SPL token mint (base58). If omitted, the mint is resolved from the deposit transaction.',
},
},
required: ['txSignature', 'viewingKey', 'spendingKey'],
required: ['txSignature', 'viewingKey', 'spendingKey', 'destinationWallet'],
},
}

export async function executeClaim(params: ClaimParams): Promise<ClaimToolResult> {
if (!params.txSignature || params.txSignature.trim().length === 0) {
throw new Error('Transaction signature is required')
}

if (!params.viewingKey || params.viewingKey.trim().length === 0) {
throw new Error('Viewing key is required to derive stealth key')
}

if (!params.spendingKey || params.spendingKey.trim().length === 0) {
throw new Error('Spending key is required to derive stealth key')
}

// Phase 1: Return the prepared shape. The actual claim_transfer instruction
// building requires @sip-protocol/sdk's ECDH derivation to compute the
// stealth private key from ephemeralPubkey + viewingKey + spendingKey.
// That wiring happens in Phase 2 when the full claim flow is implemented.
const network = loadNetworkConfig().clusterName
const connection = createConnection(network)

let ctx
try {
ctx = await resolveStealthContext(connection, params.txSignature)
} catch (err) {
if (err instanceof StealthContextError) {
throw new Error(`Cannot resolve stealth payment: ${err.message}`)
}
throw err
}

const mintBase58 = params.mint ?? ctx.mint
if (!mintBase58) {
throw new Error('Internal: resolveStealthContext returned no mint and no override was provided')
}
const destinationAddress = params.destinationWallet
const viewingPrivateKey = normalizeKey(params.viewingKey)
const spendingPrivateKey = normalizeKey(params.spendingKey)

let sdkResult: SolanaClaimResult
try {
sdkResult = await claimStealthPayment({
connection,
stealthAddress: ctx.stealthAddress,
ephemeralPublicKey: ctx.ephemeralPublicKey,
viewingPrivateKey,
spendingPrivateKey,
destinationAddress,
mint: new PublicKey(mintBase58),
})
} catch (err) {
const detail = err instanceof Error ? err.message : String(err)
throw new Error(`Claim broadcast failed: ${detail}`)
}

return {
action: 'claim',
txSignature: params.txSignature,
status: 'awaiting_signature',
status: 'confirmed',
depositTxSignature: params.txSignature,
signature: sdkResult.txSignature,
destinationWallet: sdkResult.destinationAddress,
amount: sdkResult.amount.toString(),
mint: mintBase58,
explorerUrl: sdkResult.explorerUrl,
message:
`Claim prepared for payment ${params.txSignature.slice(0, 12)}... ` +
`Stealth key derived. Awaiting signature to transfer tokens to your wallet.`,
serializedTx: null,
details: {
stealthKeyDerived: true,
destinationWallet: params.destinationWallet ?? null,
note: 'The stealth private key is ephemeral — it exists only for this claim and is never stored.',
},
`Claimed payment ${params.txSignature.slice(0, 12)}... → claim tx ${sdkResult.txSignature.slice(0, 12)}... ` +
`(${sdkResult.amount.toString()} units to ${sdkResult.destinationAddress.slice(0, 8)}...)`,
}
}

/** Strip 0x prefix and validate hex shape for SDK consumption. */
function normalizeKey(key: string): `0x${string}` {
const stripped = key.startsWith('0x') ? key.slice(2) : key
if (!/^[0-9a-fA-F]+$/.test(stripped)) {
throw new Error('Key must be hex (with or without 0x prefix)')
}
return `0x${stripped.toLowerCase()}` as `0x${string}`
}
1 change: 1 addition & 0 deletions packages/agent/src/tools/consolidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export async function executeConsolidate(
stealthAddress: payment.stealthAddress.toBase58(),
viewingKey: params.viewingKey,
spendingKey: params.spendingKey,
destinationWallet: params.wallet,
},
wallet_signature: params.walletSignature ?? 'pending',
next_exec: executesAt,
Expand Down
Loading
Loading