# @zkp2p/zkp2p-attestation

Client-side verifier and encryptor for ZKP2P Nitro payment, dispute, identity, and seller-credential flows.

The package runs in Chrome MV3 service workers, browser DOM contexts, React Native with a Web Crypto polyfill, and Node >= 20. Library entrypoints use `fetch`, `SubtleCrypto`, `crypto.getRandomValues`, and `Uint8Array`; they do not import `node:*` modules or use `Buffer`.

## Install

```bash
yarn add @zkp2p/zkp2p-attestation
```

In the protocol coordination workspace, `bin/workspace refresh-packages`
creates an ignored local package link for declared consumers. Standalone builds
and deployments must install a published package version.

## Environment Init

Use the client factory when the host owns an environment variable:

```ts
import { createNitroAttestationClient } from "@zkp2p/zkp2p-attestation";

const nitro = createNitroAttestationClient({
  environment: process.env.ZKP2P_ATTESTATION_ENV === "production" ? "production" : "staging",
});
```

Environment defaults:

| Environment | Service URL | PCR8 source |
|---|---|---|
| `staging` | `https://attestation-service-staging.zkp2p.xyz` | bundled staging pin |
| `production` | `https://attestation-service.zkp2p.xyz` | bundled production pin |

Callers can still pass `attestationServiceUrl` and `trust.expectedPcr8Hex` directly. Explicit trust pins always win. Bundled trust is limited to the canonical staging and production hostnames.

## Fast Path

Wise uploads use the server-derived payee id path:

```ts
const encryptedUpload = await nitro.createEncryptedSellerCredentialUpload({
  platform: "wise",
  sessionMaterial: {
    apiToken: "<wise-api-token>",
    // Optional. Omit for single-profile PATs; include after a
    // WISE_PROFILE_SELECTION_REQUIRED response for multi-profile PATs.
    profileId: "41246868",
  },
});

await fetch(`${attestationServiceUrl}/seller/credentials/wise`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ encryptedUpload }),
});
```

This fetches `GET /attestation?nonce=...`, verifies the Nitro document to the pinned AWS root and PCR8, extracts the attested seller-upload RSA SPKI, and returns a compact JWE for `POST /seller/credentials/:platform`.

For the same flow without hand-rolling the POST:

```ts
const credentialBundle = await nitro.uploadSellerCredential({
  platform: "wise",
  sessionMaterial: {
    apiToken: "<wise-api-token>",
    profileId: "41246868",
  },
});

const verifiedCredentialBundle = verifySellerCredentialBundle(credentialBundle, {
  trustedSigners,
  expectedPlatform: "wise",
  expectedPayeeIdHash: hashUtf8("<wise-recipient-id>"),
});
```

Venmo, Cash App, and PayPal still require a top-level `payeeId`; existing callers can keep using the same call shape:

```ts
const encryptedUpload = await nitro.createEncryptedSellerCredentialUpload({
  payeeId: "1130030979",
  sessionMaterial: venmoSessionMaterial,
});
```

PayPal Gmail OAuth seller uploads use the same app-selection model. Pass the seller PayPal email as `payeeId` and use `sessionMaterial: { credentialSource: "google_oauth", authorizationCode, redirectUri }`; the service validates Gmail access against paypal.com DKIM-signed payment receipts. To onboard through a partner-owned Google OAuth app, include `oauthClient: { clientId, clientSecret }` inside the encrypted `sessionMaterial`. The PayPal partner client secret is handled as seller credential material: it rides only in the compact JWE upload plaintext, is sealed with the Gmail refresh token in the KMS-envelope seller credential bundle, and is reused only to refresh that seller's Gmail access token.

Buyer TEE requests use the same attested upload key and compact JWE envelope. The typed helper encrypts the captured
session material, posts `{ encryptedSessionMaterial, params, chainId, intent }` to
`POST /buyer/verify/:platform/:actionType`, unwraps the service envelope, and returns a typed `AttestationOutput`.
The service does not enforce capture-age or one-use replay limits for buyer TEE session material; verification depends
on the upstream session still being active. A leaked encrypted JWE is therefore valid for the upstream session
lifetime — treat any accidental disclosure as equivalent to leaking the underlying upstream credential (cookies, PAT,
etc.) and rotate the upstream session.

```ts
const attestation = await nitro.verifyBuyerTeePayment({
  platform: "venmo",
  actionType: "transfer_venmo",
  sessionMaterial: {
    Cookie: "<captured-cookie-header>",
    "User-Agent": "<captured-user-agent>",
  },
  params: { SENDER_ID: "<venmo-account-id>", index: 0 },
  chainId,
  intent,
});

const verifiedAttestation = verifyBuyerTeePaymentAttestation(attestation, {
  trustedSigners,
  expectedPlatform: "venmo",
  expectedActionType: "transfer_venmo",
  expectedDomain: { chainId, verifyingContract },
  expectedIntentHash: intent.intentHash,
});
```

The helper is a thin wrapper over the existing wire format; callers can still hand-roll the final POST:

```ts
const encryptedSessionMaterial = await nitro.createEncryptedBuyerTeeSessionMaterial({
  platform: "wise",
  actionType: "transfer_wise",
  sessionMaterial: { "X-Access-Token": "<captured-token>" },
});

await fetch(`${attestationServiceUrl}/buyer/verify/wise/transfer_wise`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    encryptedSessionMaterial,
    params: { PROFILE_ID: "41246868", TRANSACTION_ID: "123456789" },
    chainId,
    intent,
  }),
});
```

Buyer TEE platform matrix:

| Platform | `actionType` | Required encrypted session material | Public params fields |
|---|---|---|---|
| `venmo` | `transfer_venmo` | `Cookie` | `SENDER_ID`, `index` |
| `cashapp` | `transfer_cashapp` | `Cookie`, `x-csrf-token`, `x-device-name`, `x-request-signature`, `x-request-uuid`, `cash-web-request`, `x-web-device-info`, `x-web-context`, `x-bt-id` | `SENDER_ID`, `index` |
| `monzo` | `transfer_monzo` | `Authorization` | `TX_ID` |
| `wise` | `transfer_wise` | `Cookie` or `X-Access-Token` | `PROFILE_ID`, `TRANSACTION_ID` |
| `revolut` | `transfer_revolut` | `Cookie`, `x-device-id` | `index` |
| `chime` | `transfer_chime` | `Cookie`, `body` | `{}` |
| `zelle` | `transfer_zelle_bofa` | `Cookie` | `index` |
| `zelle` | `transfer_zelle_chase` | `Cookie`, `x-jpmc-channel`, `x-jpmc-csrf-token`, `Referer`, `Origin` | `index` |
| `zelle` | `transfer_zelle_citi` | `Cookie` | `index` |
| `paypal` | `transfer_paypal` | `Cookie` | `PAYMENT_ID` |
| `alipay` | `transfer_alipay` | `Cookie` | `TRADE_NO` |

Zelle buyer verification uses only `platform: "zelle"` with bank-specific action types. Every Zelle action attests
`intent.paymentMethod = hashUtf8("zelle")`; direct bank-specific platforms are unsupported.

Per-platform session-material types require these headers using the canonical names shown above while still allowing
additional captured headers. Captured request bodies are session material because they can contain sensitive data and
are encrypted before being sent to the service.

Dispute verification uses the same attested key and compact JWE shape. The complete raw email stays encrypted, and all
payment, dispute, and amount fields come from its authenticated contents rather than public params.

```ts
const dispute = await nitro.verifyDispute({
  platform: "venmo",
  actionType: "dispute_venmo",
  sessionMaterial: { rawEmail: completeRfc5322Email },
  params: {},
  chainId,
  intentHash,
});

const verifiedDispute = verifyDisputeAttestation(dispute, {
  trustedSigners,
  expectedPlatform: "venmo",
  expectedActionType: "dispute_venmo",
  expectedDomain: { chainId, verifyingContract: disputeVerifier },
  expectedIntentHash: intentHash,
});
```

Venmo uses `dispute_venmo`; PayPal uses `dispute_paypal`. Both accept only `{ rawEmail }` encrypted session material
and `{}` public params. `verifyDisputeAttestation` checks the DisputeVerifier EIP-712 domain, the same trusted
attestation-service witnesses used by payment and identity, the request intent, the signed data hash, and exact parity
between `disputeDetails` and `encodedDisputeDetails`.

Identity requests use the same attested upload key and compact JWE plaintext shape as buyer TEE, but with a narrower
platform/action matrix. The typed helper encrypts captured session material, posts
`{ platform, actionType, callerAddress, encryptedSessionMaterial, params }` to `POST /identity`, unwraps the service response, and
returns an `IdentityAttestationOutput`. `verifyIdentityAttestation` checks the EIP-712 signature, trusted signer,
platform/action binding, expected caller address, expected payee hash, canonical identity `dataHash`, and `validUntil`,
then returns normalized identity details.

```ts
const identityPayload = await nitro.requestIdentityAttestation({
  platform: "wise",
  actionType: "register_wise",
  callerAddress: "0x0000000000000000000000000000000000000002",
  sessionMaterial: {
    Cookie: "<captured-cookie-header>",
    "X-Access-Token": "<captured-token>",
  },
  params: { PROFILE_ID: "41246868" },
});

const identity = verifyIdentityAttestation(identityPayload, {
  trustedSigners,
  expectedPlatform: "wise",
  expectedActionType: "register_wise",
  expectedCallerAddress: "0x0000000000000000000000000000000000000002",
  expectedPayeeIdHash: hashUtf8("<wise-recipient-id>"),
});
// identity: { payeeIdHash, payeeId, username, metadata, ... }
```

Identity platform matrix:

| Platform | `actionType` | Required encrypted session material | Public params fields |
|---|---|---|---|
| `venmo` | `register_venmo` | `Cookie` | `SENDER_ID` |
| `paypal` | `register_paypal` | `Cookie` | `{}` |
| `wise` | `register_wise` | `Cookie`, `X-Access-Token` | `PROFILE_ID` |
| `cashapp` | `register_cashapp` | `sessionCookie`, `requestPayload`, optional `requestHeaders` | `{}` |
| `alipay` | `register_alipay` | `Cookie` | `{}` |

This matrix is intentionally limited to the public `POST /identity` request flow. A Monobank seller credential
upload can additionally return a typed `IdentityAttestationOutputFor<"monobank">` with `register_monobank`; consumers
can pass that response directly to `verifyIdentityAttestation`, but cannot request it through `POST /identity`.

For Venmo identity, pass `SENDER_ID` in public params and include a replayable `Cookie` header in encrypted session
material. The service verifies the authenticated account id, derives the stories URL from `SENDER_ID`, and requires
Venmo to return a valid stories response with an explicit `stories` array.

For Cash App identity, pass the replayable Cash App session cookie as `sessionMaterial.sessionCookie` and the canonical
`MY_ACTIVITY_WEB_V2` activity request body as `sessionMaterial.requestPayload`. Include captured replayable headers in
`sessionMaterial.requestHeaders` when needed; the service ignores public params for this flow.

For Alipay identity, pass a replayable `Cookie` header. The service fetches the authenticated account page and signs
the historical transfer-recipient preimage: normalized recipient name concatenated directly with the redacted,
lowercased email. CJK names use their final CJK character to match Alipay's masked transfer detail; non-CJK names keep
the existing token normalization. The normalized full email remains `identity.username`; the authenticated account's
16-digit PID is returned as `identity.metadata.alipayPid` for payment routing.

Buyer TEE and identity expose discriminated unions keyed by `platform` and `actionType`, including per-platform
`params`, so invalid pairings fail during TypeScript compilation. Seller credential upload keeps its existing
platform narrowing (Wise and Monobank derive payee id from the credential; Venmo, Cash App, and PayPal require
`payeeId`) but
does not add exhaustive `sessionMaterial` typing because the service owns connector-specific validation and the SDK
would otherwise duplicate the wire contract.

## Cache-Friendly Path

```ts
const verified = await nitro.fetchAndVerifyAttestation();

const encryptedUpload = await nitro.encryptSellerCredentialUpload({
  key: verified.attestedSellerUploadKey,
  platform: "wise",
  sessionMaterial: {
    apiToken: "<wise-api-token>",
  },
});
```

For buyer TEE flows with a cached attestation:

```ts
const encryptedSessionMaterial = await nitro.encryptBuyerTeeSessionMaterial({
  key: verified.attestedSellerUploadKey,
  platform: "venmo",
  actionType: "transfer_venmo",
  sessionMaterial: {
    Cookie: "<captured-cookie-header>",
  },
});
```

For identity flows with a cached attestation:

```ts
const encryptedSessionMaterial = await nitro.encryptIdentitySessionMaterial({
  key: verified.attestedSellerUploadKey,
  platform: "paypal",
  actionType: "register_paypal",
  sessionMaterial: {
    Cookie: "<captured-cookie-header>",
  },
});
```

Identity helpers emit `{ platform, actionType, sessionMaterial, boundPubKeySha256 }` inside the compact JWE, exactly
like buyer TEE capture. The outer `callerAddress` is required by `POST /identity` and included in the signed typed data,
but is not encrypted into or authenticated by the session envelope.

For dispute flows with a cached attestation:

```ts
const encryptedSessionMaterial = await nitro.encryptDisputeSessionMaterial({
  key: verified.attestedSellerUploadKey,
  platform: "paypal",
  actionType: "dispute_paypal",
  sessionMaterial: { rawEmail: completeRfc5322Email },
});
```

The attested SPKI is stable within one enclave process. If the enclave restarts, cached keys become stale and upload decrypt will fail; fetch a fresh attestation for a new session.

## React Native Wiring

React Native must provide Web Crypto-compatible primitives when they are not on `globalThis.crypto`:

```ts
const nitro = createNitroAttestationClient({
  environment: "staging",
  subtle: webCrypto.subtle,
  getRandomValues: (out) => webCrypto.getRandomValues(out),
  fetch,
});
```

Any conforming `SubtleCrypto` works. The package does not require `Buffer`.

## Direct Entrypoints

```ts
import {
  createEncryptedBuyerTeeSessionMaterial,
  createEncryptedDisputeSessionMaterial,
  createEncryptedIdentitySessionMaterial,
  createEncryptedSellerCredentialUpload,
  encryptBuyerTeeSessionMaterial,
  encryptDisputeSessionMaterial,
  encryptIdentitySessionMaterial,
  encryptSellerCredentialUpload,
  fetchAndVerifyAttestation,
  getUnifiedPaymentVerifierDomainSeparator,
  getDisputeVerifierDomainSeparator,
  hashUtf8,
  requestIdentityAttestation,
  uploadSellerCredential,
  verifyBuyerTeePaymentAttestation,
  verifyBuyerTeePayment,
  verifyDispute,
  verifyDisputeAttestation,
  verifyIdentityAttestation,
  verifySellerCredentialBundle,
} from "@zkp2p/zkp2p-attestation";
```

Direct functions accept `attestationServiceUrl`, `trust`, `fetch`, `subtle`, `getRandomValues`, `now`, `onWarning`, and `timeoutMs` overrides. If no caller or bundled PCR8 pin exists and `trust.strictPin !== true`, the verifier falls back to the service-advertised PCR8 and emits `TRUST_PIN_NOT_PROVIDED`; production callers should pin.

## PCR8 Rotation Runbook

1. Update `src/trust/pins.ts` from the deployment `STATE.md` published by the attestation-service team.
2. Refresh `tests/golden/staging-2026-05-05.raw.json` and expected metadata if staging rotates.
3. Run `yarn test:coverage` and `STAGING_E2E=true yarn test:integration`.
4. Release a patch version for PCR8-only rotations. Use a major version for wire-format or algorithm changes.

## CLI

The existing verifier is preserved on top of the library:

```bash
yarn verify --service https://attestation-service-staging.zkp2p.xyz
```

Optional EIP-712 signer cross-check remains available through `--verify-signature`.
