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
810 changes: 810 additions & 0 deletions app/landing/public/schemas/v0.1/envelope.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/ows-adapter/src/__tests__/ows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe('toOwsSignAndSend', () => {
})
envelope.risk = {
action: 'WARN',
warnings: [{ code: 'unbounded-approval', severity: 'WARN', message: 'MAX approval' }],
warnings: [{ code: 'unbounded-approval', severity: 'high', message: 'MAX approval' }],
}

const payload = toOwsSignAndSend(envelope)
Expand Down
24 changes: 24 additions & 0 deletions packages/tx-protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# @txkit/tx-protocol

## [0.1.0-alpha.2] - 2026-05-11

Aligns the reference implementation with the ERC `Prepared Transaction Envelope` body draft (Phase 3 complete, ready for Phase 4 red-team). Five breaking changes follow.

### Breaking changes

- **Drop `version` field from `BaseEnvelope`.** The `$schema` URL is the version contract per ERC §1.2: envelopes MUST NOT carry a separate `version` field. `SPEC_VERSION` constant and `SpecVersion` type removed from exports. `SPEC_SCHEMA_URL` retained as the canonical version identifier.
- **Drop reserved post-quantum signature schemes from `SignatureScheme`.** `'ml-dsa-44' | 'ml-dsa-65' | 'ml-dsa-87' | 'slh-dsa-sha2-128s'` literals removed. The type remains an open string per ERC §3.2: implementations MAY use any scheme by agreement between producer and consumer, including future post-quantum algorithms, without revising this type.
- **Rename `'smart-account-7702'` → `'delegated-eoa'` in `RequiredAccountType`.** Removes the explicit EIP-7702 callout from the normative enum per ERC review decision H3. Same semantic (an EOA with installed code), neutral naming.
- **Close `RiskWarning.severity` enum to four lowercase values.** Was `'INFO' | 'WARN' | 'CRITICAL'`. Now `'low' | 'medium' | 'high' | 'critical'`, matching ERC §7 (CVSS-style severity ladder). Policy engines now have a fixed vocabulary across vendors.
- **Constrain `ScannerVerdict.verdict` to a closed enum.** Was `string`. Now `'ALLOW' | 'WARN' | 'BLOCK'`, matching `RiskAssessment.action` and ERC §7. Scanner verdicts share the same three-valued action language as the overall risk verdict.

### Spec sync

Canonical spec at `packages/tx-protocol/spec/v0.1/prepared-transaction.md` synced with the same five changes (drop `version`, drop PQ enums, rename account type, close severity, close verdict).

### Migration

Producers that emitted `version: '0.1'` MUST stop including the field. Consumers MAY accept old envelopes with the field for one release cycle by ignoring it. Producers using PQ scheme literals MUST switch to the open-string form; consumers MUST NOT reject envelopes solely because the scheme is not one of the recognised three values (§3.2). Producers using `'smart-account-7702'` MUST rename to `'delegated-eoa'`. Producers emitting `RiskWarning` MUST use lowercase severity values from the new four-value enum. Producers emitting `ScannerVerdict.verdict` MUST use one of `'ALLOW' | 'WARN' | 'BLOCK'`.

### Reference

ERC body draft is complete in private wiki (`projects/txkit-erc-draft/erc-draft_prepared_tx_envelope.md`). Will be submitted to `ethereum/ERCs` in Phase 6 after Phase 4 red-team (security + standards-process agents) and Phase 5 pre-Magicians outreach.

## [0.1.0-alpha.0] - 2026-04-29

Initial alpha release. Defines the `PreparedEnvelope` shape for agent-to-wallet
Expand Down
145 changes: 145 additions & 0 deletions packages/tx-protocol/examples/multicall-batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* packages/tx-protocol/examples/multicall-batch.ts
*
* Example: PreparedEnvelope (kind: 'evm-batch') for an atomic
* approve + swap flow via ERC-5792 wallet_sendCalls.
*
* Demonstrates:
* - evm-batch kind with calls.length >= 2 (Spec §5.1)
* - atomicRequired capability per ERC-5792 (Spec §8.1)
* - Multi-call tokenMovements enumeration covering approve + transfer
* in the same envelope (Spec §5.6)
*
* Run:
* pnpm exec tsx packages/tx-protocol/examples/multicall-batch.ts
*/

import {
createEvmBatch,
deserialize,
serialize,
validateEnvelope,
} from '@txkit/tx-protocol'
import type { EvmTxContent } from '@txkit/tx-protocol'

const USER = '0xdeadBeefdeaDbEEfDEaDbeefdEADBEeFDEaDBEEf' as const
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const
const UNISWAP_V3_ROUTER = '0xE592427A0AEce92De3Edee1F18E0157C05861564' as const

// 1,000 USDC = 1_000 * 10^6 (USDC has 6 decimals)
const AMOUNT_RAW = '1000000000'

const content: EvmTxContent = {
chain: 'eip155:1',
chainId: 1,
from: USER,
calls: [
{
// Call 1: approve USDC for 1000 to Uniswap V3 Router.
// approve(spender=router, amount=1_000_000_000)
to: USDC,
data: '0x095ea7b3000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000003b9aca00',
value: '0x0',
operation: 'call',
},
{
// Call 2: exactInputSingle USDC -> ETH on Uniswap V3 (0.05% pool).
// Calldata truncated for brevity; in production the consumer decodes
// the full selector + struct against the canonical Router ABI.
to: UNISWAP_V3_ROUTER,
data: '0x414bf389000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
value: '0x0',
operation: 'call',
},
],
validity: {
notAfter: Math.floor(Date.now() / 1000) + 1800,
nonceKind: 'sequential',
},
description: {
short: 'Swap 1000 USDC for ETH (atomic approve + swap)',
long: 'Atomic batch: approve 1000 USDC to Uniswap V3 Router, then swap 1000 USDC for ETH via exactInputSingle on the 0.05% pool. Either both calls execute or both revert per ERC-5792 atomicRequired.',
action: 'swap',
},
metadata: {
protocol: 'uniswap-v3',
tokenMovements: [
{
token: USDC,
standard: 'erc20',
symbol: 'USDC',
decimals: 6,
amount: AMOUNT_RAW,
kind: 'approve',
from: USER,
to: UNISWAP_V3_ROUTER,
isUnlimited: false,
},
{
token: USDC,
standard: 'erc20',
symbol: 'USDC',
decimals: 6,
amount: AMOUNT_RAW,
kind: 'transfer',
from: USER,
to: UNISWAP_V3_ROUTER,
},
],
counterparties: [
{
address: USDC,
role: 'unknown',
label: 'USDC',
labelSource: 'protocol_directory',
},
{
address: UNISWAP_V3_ROUTER,
role: 'swap-venue',
label: 'Uniswap V3 Router',
labelSource: 'protocol_directory',
},
],
estimatedGas: '180000',
},
decoderRef: 'uniswap-v3/router/exactInputSingle',
}

const envelope = createEvmBatch(content, {
origin: { url: 'https://app.uniswap.org', verifyStatus: 'VERIFIED' },
producer: {
id: 'did:web:uniswap.org#agent-tools',
name: 'uniswap/agent-tools',
},
capabilities: {
atomicRequired: true,
},
})

const result = validateEnvelope(envelope)
if (!result.ok) {
console.error('Validation failed:', result.error)
console.error(result.issues)
process.exit(1)
}

console.log('Valid PreparedEnvelope:', result.value.content.description.short)
console.log('Kind:', result.value.kind)
console.log('Chain:', result.value.content.chain)
console.log('Calls:', result.value.content.calls.length)
console.log('Atomic required:', result.value.capabilities?.atomicRequired)
console.log('Origin verify status:', result.value.origin?.verifyStatus ?? 'none')
console.log('Expires at:', result.value.expiresAt)
if (result.warnings) {
console.log('Advisories:')
for (const warning of result.warnings) {
console.log(` [${warning.severity}] ${warning.path}: ${warning.message}`)
}
}

const json = serialize(envelope)
console.log('\nSerialized payload (first 200 chars):')
console.log(json.slice(0, 200) + '...')

const restored = deserialize(json)
console.log('\nRoundtrip kind preserved:', restored.kind === envelope.kind)
9 changes: 6 additions & 3 deletions packages/tx-protocol/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@txkit/tx-protocol",
"version": "0.1.0-alpha.0",
"version": "0.1.0-alpha.2",
"description": "Open protocol for prepared Web3 operations - envelope + content discriminated union, CAIP-2 chain ids, EIP-5792 aligned batches, EIP-712 signature requests. Post-quantum ready producer signatures.",
"license": "MIT",
"author": "Michael Diamond",
Expand Down Expand Up @@ -66,15 +66,18 @@
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run",
"gen:schema": "tsx scripts/generate-schema.ts"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"tsup": "^8.4.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"vitest": "^4.1.1"
"vitest": "^4.1.1",
"zod-to-json-schema": "^3.25.2"
},
"sideEffects": false
}
50 changes: 50 additions & 0 deletions packages/tx-protocol/scripts/generate-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Generates a JSON Schema (Draft 2020-12 compatible) from the zod source of truth
* `packages/tx-protocol/src/schema.ts`, and writes it to:
* `app/landing/public/schemas/v0.1/envelope.json`
*
* The generated file is served at `https://txkit.dev/schemas/v0.1/envelope.json`,
* which is the canonical `$schema` URL referenced by every prepared envelope per
* the ERC submission Specification section 1.2.
*
* Run via:
* pnpm --filter @txkit/tx-protocol gen:schema
*/

import { writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { zodToJsonSchema } from 'zod-to-json-schema'
import { preparedEnvelopeSchema } from '../src/schema'

const __dirname = dirname(fileURLToPath(import.meta.url))

const SCHEMA_ID = 'https://txkit.dev/schemas/v0.1/envelope.json'
const SCHEMA_TITLE = 'PreparedTransaction Envelope v0.1'
const SCHEMA_DESCRIPTION =
'Off-chain envelope for prepared-but-not-yet-signed transactions, batches, and signature requests. ' +
'Reference implementation for the Ethereum ERC "Prepared Transaction Envelope" specification.'

const OUTPUT_PATH = resolve(
__dirname,
'../../../app/landing/public/schemas/v0.1/envelope.json',
)

const jsonSchema = zodToJsonSchema(preparedEnvelopeSchema, {
name: 'PreparedEnvelope',
$refStrategy: 'root',
target: 'jsonSchema2019-09',
})

const root = jsonSchema as Record<string, unknown>
const augmented = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: SCHEMA_ID,
title: SCHEMA_TITLE,
description: SCHEMA_DESCRIPTION,
...root,
}

writeFileSync(OUTPUT_PATH, JSON.stringify(augmented, null, 2) + '\n', 'utf8')

console.log(`Wrote ${OUTPUT_PATH}`)
26 changes: 18 additions & 8 deletions packages/tx-protocol/spec/v0.1/prepared-transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ Neither layer is mandatory in v0.1. Both are recommended. Wallets MAY treat miss

```typescript
interface BaseEnvelope<K extends string, C> {
$schema: string // "https://txkit.dev/schemas/v0.1/envelope.json"
version: '0.1'
$schema: string // "https://txkit.dev/schemas/v0.1/envelope.json" - version contract
kind: K // discriminator; see §3
id?: string // idempotency, 4096 chars max (EIP-5792 convention)
issuedAt: string // RFC3339 UTC
Expand All @@ -55,11 +54,13 @@ interface BaseEnvelope<K extends string, C> {
}
```

The `$schema` URL is the version contract per ERC `Prepared Transaction Envelope`. Envelopes do not carry a separate `version` field; the URL path identifies the spec version.

### 2.1 Required vs recommended

| Field | Required | Notes |
|---|---|---|
| `$schema`, `version`, `kind`, `issuedAt`, `content` | yes | |
| `$schema`, `kind`, `issuedAt`, `content` | yes | |
| `content` kind-specific required sub-fields | yes | see per-kind §6 |
| `id` | no | wallets MAY assign if producer omits; required for `wallet_getCallsStatus` |
| `expiresAt` | no | if present, MUST equal `content.validity.notAfter` for tx kinds |
Expand Down Expand Up @@ -105,10 +106,11 @@ interface Producer {
signature?: ProducerSignature
}

// Open string per the ERC. Implementations SHOULD support secp256k1, ed25519,
// and p256. Other schemes (including future post-quantum algorithms) MAY be
// used without revising this type.
type SignatureScheme =
| 'secp256k1' | 'ed25519' | 'p256'
| 'ml-dsa-44' | 'ml-dsa-65' | 'ml-dsa-87' // PQ reserved (NIST FIPS 204)
| 'slh-dsa-sha2-128s' // PQ reserved (NIST FIPS 205)
| string // open for future schemes

interface ProducerSignature {
Expand Down Expand Up @@ -260,8 +262,16 @@ This closes the blind-signing gap for Permit, Permit2, CoW orders, UniswapX Dutc
interface RiskAssessment {
action: 'ALLOW' | 'WARN' | 'BLOCK'
score?: number // 0-100
warnings: Array<{ code, severity, message }>
scanners?: Array<{ provider, verdict, url? }>
warnings: Array<{
code: string
severity: 'low' | 'medium' | 'high' | 'critical'
message: string
}>
scanners?: Array<{
provider: string
verdict: 'ALLOW' | 'WARN' | 'BLOCK'
url?: string
}>
}
```

Expand All @@ -274,7 +284,7 @@ interface Capabilities {
atomicRequired?: boolean // EIP-5792
paymasterService?: { url: string; sponsor?: Address } // ERC-4337 paymaster hint
permissions?: { context: Hex; type: string; expiry? } // ERC-7715 session
requiresAccountType?: 'eoa' | 'smart-account-7702' | 'erc-4337'
requiresAccountType?: 'eoa' | 'delegated-eoa' | 'erc-4337'
[k: string]: unknown // open for vendors; MUST use 'x-' prefix
}
```
Expand Down
9 changes: 4 additions & 5 deletions packages/tx-protocol/src/__tests__/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
IMPLEMENTED_KINDS,
RESERVED_KINDS,
SPEC_SCHEMA_URL,
SPEC_VERSION,
createEvmBatch,
createEvmTx,
createSignature,
Expand Down Expand Up @@ -75,10 +74,10 @@ describe('envelope + evm-tx validation', () => {
}
})

it('populates $schema, version and issuedAt via createEvmTx', () => {
it('populates $schema and issuedAt via createEvmTx (no version field per spec)', () => {
const env = evmTx()
expect(env.$schema).toBe(SPEC_SCHEMA_URL)
expect(env.version).toBe(SPEC_VERSION)
expect((env as Record<string, unknown>).version).toBeUndefined()
expect(typeof env.issuedAt).toBe('string')
})

Expand Down Expand Up @@ -285,7 +284,7 @@ describe('origin + risk + capabilities', () => {
risk: {
action: 'WARN',
warnings: [
{ code: 'NEW_COUNTERPARTY', severity: 'INFO', message: 'first interaction' },
{ code: 'NEW_COUNTERPARTY', severity: 'low', message: 'first interaction' },
],
},
capabilities: { atomicRequired: true, paymasterService: { url: 'https://pm.example' } },
Expand All @@ -308,7 +307,7 @@ describe('serialize / deserialize', () => {
})

it('deserialize throws on invalid JSON shape', () => {
expect(() => deserialize('{"version":"0.2","kind":"evm-tx"}')).toThrow(/deserialize:/)
expect(() => deserialize('{"kind":"evm-tx"}')).toThrow(/deserialize:/)
})
})

Expand Down
Loading
Loading