diff --git a/.github/scripts/install-evm-key-scan-hook.sh b/.github/scripts/install-evm-key-scan-hook.sh index b140b724..230708bf 100755 --- a/.github/scripts/install-evm-key-scan-hook.sh +++ b/.github/scripts/install-evm-key-scan-hook.sh @@ -33,7 +33,7 @@ validate_policy() { exit 2 } [[ -d "$policy_dir/.git" ]] || return 1 - origin=$(git -C "$policy_dir" remote get-url origin) || { + origin=$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$policy_dir" remote get-url origin) || { printf 'Refusing unreadable policy cache: %s\n' "$policy_dir" >&2 exit 2 } @@ -44,11 +44,11 @@ validate_policy() { exit 2 ;; esac - [[ "$(git -C "$policy_dir" rev-parse HEAD)" == "$CENTRAL_POLICY_SHA" ]] || { + [[ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$policy_dir" rev-parse HEAD)" == "$CENTRAL_POLICY_SHA" ]] || { printf 'Refusing stale policy cache: %s\n' "$policy_dir" >&2 exit 2 } - [[ -z "$(git -C "$policy_dir" status --porcelain --untracked-files=all -- ':!/.tools')" ]] || { + [[ -z "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$policy_dir" status --porcelain --untracked-files=all -- ':!/.tools')" ]] || { printf 'Refusing modified policy cache: %s\n' "$policy_dir" >&2 exit 2 } @@ -102,6 +102,8 @@ if [[ "$action" == run ]]; then exit 2 } exec env \ + -u GIT_DIR \ + -u GIT_WORK_TREE \ VANA_SECRET_SCAN_HOME="$policy_dir" \ VANA_SECRET_SCAN_EXPECTED_SHA="$CENTRAL_POLICY_SHA" \ "$policy_dir/hooks/pre-push" "$@" diff --git a/packages/vana-sdk/scripts/validate-package-imports.ts b/packages/vana-sdk/scripts/validate-package-imports.ts index 21964ea8..f5408b18 100644 --- a/packages/vana-sdk/scripts/validate-package-imports.ts +++ b/packages/vana-sdk/scripts/validate-package-imports.ts @@ -93,7 +93,8 @@ function validateTypeScriptConsumer(consumerDir: string): void { join(consumerDir, "index.ts"), [ 'import { createSessionRelayBuilderClient, SessionRelayError, type SessionRelayInitResult } from "@opendatalabs/vana-sdk/session-relay";', - 'import { buildEscrowPaymentHeader, type EscrowPaymentConfig, type EscrowPaymentHeaderConfig, type SignTypedDataFn } from "@opendatalabs/vana-sdk/server";', + 'import { buildEscrowPaymentHeader, type EscrowPaymentConfig, type EscrowPaymentHeaderConfig, type PersonalServerPaymentOperation, type SignTypedDataFn } from "@opendatalabs/vana-sdk/server";', + 'import { buildWithdrawAuthorizationTypedData, createEscrowGatewayClient, EscrowWithdrawalLifecycleError, EscrowWithdrawalRejectionError, type EscrowWithdrawalResult } from "@opendatalabs/vana-sdk/node";', 'import { buildEscrowPaymentHeader as buildDirectEscrowPaymentHeader } from "@opendatalabs/vana-sdk/direct/escrow-payment";', 'import { readPersonalServerData } from "@opendatalabs/vana-sdk/direct/personal-server-read";', "", @@ -123,6 +124,14 @@ function validateTypeScriptConsumer(consumerDir: string): void { " legacyConfig;", "void headerOnlyInput;", "void legacyInput;", + "const paymentOperation = {} as PersonalServerPaymentOperation;", + "const withdrawalResult = {} as EscrowWithdrawalResult;", + "void paymentOperation;", + "void withdrawalResult;", + "void buildWithdrawAuthorizationTypedData;", + "void createEscrowGatewayClient;", + "void EscrowWithdrawalLifecycleError;", + "void EscrowWithdrawalRejectionError;", "void buildDirectEscrowPaymentHeader;", "void readPersonalServerData;", "", @@ -173,6 +182,25 @@ try { console.log(`✓ ${specifier}`); } + run( + "node", + [ + "--input-type=module", + "-e", + 'const sdk = await import("@opendatalabs/vana-sdk/server"); if (typeof sdk.buildEscrowPaymentHeader !== "function") throw new Error("Server entry point is missing buildEscrowPaymentHeader");', + ], + consumerDir, + ); + run( + "node", + [ + "--input-type=module", + "-e", + 'const sdk = await import("@opendatalabs/vana-sdk/node"); for (const name of ["buildWithdrawAuthorizationTypedData", "createEscrowGatewayClient", "EscrowWithdrawalLifecycleError", "EscrowWithdrawalRejectionError"]) if (!(name in sdk)) throw new Error(`Node entry point is missing ${name}`);', + ], + consumerDir, + ); + for (const specifier of browserBlockedImports) { validateBrowserBlockedImport(specifier, consumerDir); } diff --git a/packages/vana-sdk/src/direct/controller.test.ts b/packages/vana-sdk/src/direct/controller.test.ts index fd4125c7..c9b5fe4e 100644 --- a/packages/vana-sdk/src/direct/controller.test.ts +++ b/packages/vana-sdk/src/direct/controller.test.ts @@ -323,9 +323,6 @@ function mockEscrowConfig( ): DirectEscrowConfig { return { client: { - submitDeposit: vi.fn(), - getEscrowBalance: vi.fn(), - syncEscrowBalance: vi.fn(), payForOp, }, escrowContract: "0x000000000000000000000000000000000000dEaD", @@ -600,9 +597,6 @@ function makeControllerWithPaymentCapture( const spyPayForOp = vi.fn(async () => payResultFixture()); const spyClient = { - submitDeposit: vi.fn(), - getEscrowBalance: vi.fn(), - syncEscrowBalance: vi.fn(), payForOp: spyPayForOp, }; diff --git a/packages/vana-sdk/src/direct/escrow-payment.test.ts b/packages/vana-sdk/src/direct/escrow-payment.test.ts index e9d91ade..1fa79863 100644 --- a/packages/vana-sdk/src/direct/escrow-payment.test.ts +++ b/packages/vana-sdk/src/direct/escrow-payment.test.ts @@ -139,9 +139,6 @@ describe("authorizeGrantPayment", () => { const signTypedData = vi.fn(async () => "0xsig" as `0x${string}`); const cfg: EscrowPaymentConfig = { client: { - submitDeposit: vi.fn(), - getEscrowBalance: vi.fn(), - syncEscrowBalance: vi.fn(), payForOp, }, escrowContract: ESCROW, @@ -262,9 +259,6 @@ describe("generic escrow payment operations", () => { const signTypedData = vi.fn(async () => "0xsig" as `0x${string}`); const cfg: EscrowPaymentConfig = { client: { - submitDeposit: vi.fn(), - getEscrowBalance: vi.fn(), - syncEscrowBalance: vi.fn(), payForOp, }, escrowContract: ESCROW, diff --git a/packages/vana-sdk/src/direct/escrow-payment.ts b/packages/vana-sdk/src/direct/escrow-payment.ts index 5f72acf3..64ddd0a5 100644 --- a/packages/vana-sdk/src/direct/escrow-payment.ts +++ b/packages/vana-sdk/src/direct/escrow-payment.ts @@ -24,7 +24,7 @@ import { NATIVE_ASSET_ADDRESS, genericPaymentDomain, type EscrowAccessRecord, - type EscrowGatewayClient, + type EscrowPaymentClient, type EscrowPayResult, type PaymentBreakdown, } from "../protocol/escrow"; @@ -115,7 +115,7 @@ export interface EscrowPaymentHeaderConfig { */ export interface EscrowPaymentConfig extends EscrowPaymentHeaderConfig { /** Client for the gateway escrow endpoints (`/v1/escrow/*`). */ - client: EscrowGatewayClient; + client: EscrowPaymentClient; } /** Map the gateway {@link PaymentBreakdown} into the public {@link DirectFeeBreakdown}. */ diff --git a/packages/vana-sdk/src/direct/personal-server-read.test.ts b/packages/vana-sdk/src/direct/personal-server-read.test.ts index 3544ea65..a2ec046a 100644 --- a/packages/vana-sdk/src/direct/personal-server-read.test.ts +++ b/packages/vana-sdk/src/direct/personal-server-read.test.ts @@ -104,9 +104,6 @@ function payResultFixture(opId: string) { function mockEscrow(payForOp = vi.fn()): EscrowPaymentConfig { return { client: { - submitDeposit: vi.fn(), - getEscrowBalance: vi.fn(), - syncEscrowBalance: vi.fn(), payForOp, }, escrowContract: "0x000000000000000000000000000000000000dEaD", diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index d2f69ed3..e76dce31 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -150,12 +150,15 @@ export { serverRegistrationDomain, builderRegistrationDomain, escrowPaymentDomain, + withdrawAuthorizationDomain, + buildWithdrawAuthorizationTypedData, GRANT_REGISTRATION_TYPES, GRANT_REVOCATION_TYPES, SERVER_REGISTRATION_TYPES, BUILDER_REGISTRATION_TYPES, ADD_DATA_TYPES, RECORD_DATA_ACCESS_TYPES, + WITHDRAW_AUTHORIZATION_TYPES, type DataPortabilityContracts, type DataPortabilityGatewayConfig, type GrantRegistrationMessage, @@ -164,6 +167,7 @@ export { type BuilderRegistrationMessage, type AddDataMessage, type RecordDataAccessMessage, + type WithdrawAuthorizationMessage, } from "./protocol/eip712"; export { PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, @@ -326,8 +330,21 @@ export { type DepositSubmissionResult, type PaymentBreakdown, type EscrowPayResult, + EscrowWithdrawalLifecycleError, + EscrowWithdrawalRejectionError, + type EscrowPaymentClient, + type EscrowWithdrawalFailureResult, + type EscrowWithdrawalRejectedResult, + type EscrowWithdrawalRejectionCode, + type EscrowWithdrawalSubmittedResult, + type EscrowWithdrawalSubmittedWithoutTransaction, + type EscrowWithdrawalSubmittedWithTransaction, + type EscrowWithdrawalSettledResult, + type EscrowWithdrawalResult, type SubmitDepositParams, type PayForOpParams, + type WithdrawFromEscrowParams, + type WithdrawNonceResponse, type EscrowGatewayClient, type SubmittedDepositEntry, type FinalizedDepositEntry, diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index 21c17240..f83f5642 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -150,12 +150,15 @@ export { serverRegistrationDomain, builderRegistrationDomain, escrowPaymentDomain, + withdrawAuthorizationDomain, + buildWithdrawAuthorizationTypedData, GRANT_REGISTRATION_TYPES, GRANT_REVOCATION_TYPES, SERVER_REGISTRATION_TYPES, BUILDER_REGISTRATION_TYPES, ADD_DATA_TYPES, RECORD_DATA_ACCESS_TYPES, + WITHDRAW_AUTHORIZATION_TYPES, type DataPortabilityContracts, type DataPortabilityGatewayConfig, type GrantRegistrationMessage, @@ -164,6 +167,7 @@ export { type BuilderRegistrationMessage, type AddDataMessage, type RecordDataAccessMessage, + type WithdrawAuthorizationMessage, } from "./protocol/eip712"; export { PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, @@ -326,8 +330,21 @@ export { type DepositSubmissionResult, type PaymentBreakdown, type EscrowPayResult, + EscrowWithdrawalLifecycleError, + EscrowWithdrawalRejectionError, + type EscrowPaymentClient, + type EscrowWithdrawalFailureResult, + type EscrowWithdrawalRejectedResult, + type EscrowWithdrawalRejectionCode, + type EscrowWithdrawalSubmittedResult, + type EscrowWithdrawalSubmittedWithoutTransaction, + type EscrowWithdrawalSubmittedWithTransaction, + type EscrowWithdrawalSettledResult, + type EscrowWithdrawalResult, type SubmitDepositParams, type PayForOpParams, + type WithdrawFromEscrowParams, + type WithdrawNonceResponse, type EscrowGatewayClient, type SubmittedDepositEntry, type FinalizedDepositEntry, diff --git a/packages/vana-sdk/src/protocol/eip712.test.ts b/packages/vana-sdk/src/protocol/eip712.test.ts index e8152102..9e44d35c 100644 --- a/packages/vana-sdk/src/protocol/eip712.test.ts +++ b/packages/vana-sdk/src/protocol/eip712.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { recoverTypedDataAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; import { ADD_DATA_TYPES, BUILDER_REGISTRATION_TYPES, @@ -8,12 +10,15 @@ import { NATIVE_VANA_ASSET, RECORD_DATA_ACCESS_TYPES, SERVER_REGISTRATION_TYPES, + WITHDRAW_AUTHORIZATION_TYPES, builderRegistrationDomain, + buildWithdrawAuthorizationTypedData, dataRegistryDomain, escrowPaymentDomain, grantRegistrationDomain, grantRevocationDomain, serverRegistrationDomain, + withdrawAuthorizationDomain, type DataPortabilityGatewayConfig, } from "./eip712"; @@ -52,6 +57,9 @@ describe("Data Portability EIP-712 helpers", () => { expect(escrowPaymentDomain(CONFIG)).toMatchObject({ verifyingContract: CONFIG.contracts.dataPortabilityEscrow, }); + expect(withdrawAuthorizationDomain(CONFIG)).toEqual( + escrowPaymentDomain(CONFIG), + ); }); it("exposes the native VANA asset sentinel", () => { @@ -93,6 +101,13 @@ describe("Data Portability EIP-712 helpers", () => { { name: "amount", type: "uint256" }, { name: "paymentNonce", type: "uint256" }, ]); + expect(WITHDRAW_AUTHORIZATION_TYPES.WithdrawAuthorization).toEqual([ + { name: "account", type: "address" }, + { name: "asset", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "withdrawNonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ]); expect(ADD_DATA_TYPES.AddData).toEqual([ { name: "ownerAddress", type: "address" }, { name: "scope", type: "string" }, @@ -108,4 +123,26 @@ describe("Data Portability EIP-712 helpers", () => { { name: "recordId", type: "bytes32" }, ]); }); + + it("builds a signed withdrawal authorization without a recipient", async () => { + const account = privateKeyToAccount( + "0x59c6995e998f97a5a0044966f094538e8a55c3611c5a70cfa2de42b44397316c", + ); + const typedData = buildWithdrawAuthorizationTypedData(CONFIG, { + account: account.address, + asset: NATIVE_VANA_ASSET, + amount: 42n, + withdrawNonce: 7n, + deadline: 1_800_000_000n, + }); + + expect(typedData.message).not.toHaveProperty("recipient"); + const signature = await account.signTypedData(typedData); + const recovered = await recoverTypedDataAddress({ + ...typedData, + signature, + }); + + expect(recovered).toBe(account.address); + }); }); diff --git a/packages/vana-sdk/src/protocol/eip712.ts b/packages/vana-sdk/src/protocol/eip712.ts index bcf9705a..431a3ab8 100644 --- a/packages/vana-sdk/src/protocol/eip712.ts +++ b/packages/vana-sdk/src/protocol/eip712.ts @@ -111,6 +111,19 @@ export function escrowPaymentDomain( ); } +/** + * Domain for a gateway-authorized escrow withdrawal. + * + * Withdrawals use the escrow contract as their verifying contract, just like + * generic payments. The distinct primary type prevents a payment signature + * from authorizing a withdrawal. + */ +export function withdrawAuthorizationDomain( + config: DataPortabilityGatewayConfig, +): TypedDataDomain { + return escrowPaymentDomain(config); +} + // grantVersion is a monotonic uint256 nonce per (grantor, grantee) pair. The // gateway rejects any registration whose version is <= the stored value, // which is the replay-attack defence now that re-registering the same pair @@ -171,6 +184,23 @@ export const GENERIC_PAYMENT_TYPES = { ], } as const; +/** + * Authorization consumed by `POST /v1/escrow/withdraw`. + * + * The current escrow contract always pays `account` itself. Do not add an + * unsigned recipient field: a future recipient capability must be introduced + * as a new, signed protocol type after the contract and gateway support it. + */ +export const WITHDRAW_AUTHORIZATION_TYPES = { + WithdrawAuthorization: [ + { name: "account", type: "address" }, + { name: "asset", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "withdrawNonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + // AddData is signed by the data point's owner — registers (scope, dataHash, // metadataHash) on DataRegistryV2. expectedVersion is the version the caller // believes is current; the contract rejects with a CAS error if it isn't. @@ -240,6 +270,33 @@ export interface GenericPaymentMessage { paymentNonce: bigint; } +/** EIP-712 message authorizing a withdrawal of an account's escrow balance. */ +export interface WithdrawAuthorizationMessage { + /** The escrow account to debit and the withdrawal recipient. */ + account: `0x${string}`; + /** Native VANA sentinel or the ERC-20 asset contract. */ + asset: `0x${string}`; + /** Base-unit amount. */ + amount: bigint; + /** Caller-managed, strictly increasing nonce for newly accepted intents. */ + withdrawNonce: bigint; + /** Unix seconds. Bounds first acceptance; exact accepted retries remain valid. */ + deadline: bigint; +} + +/** Builds the typed data a wallet must sign before calling the withdraw API. */ +export function buildWithdrawAuthorizationTypedData( + config: DataPortabilityGatewayConfig, + message: WithdrawAuthorizationMessage, +) { + return { + domain: withdrawAuthorizationDomain(config), + types: WITHDRAW_AUTHORIZATION_TYPES, + primaryType: "WithdrawAuthorization" as const, + message, + }; +} + export interface AddDataMessage { ownerAddress: `0x${string}`; scope: string; diff --git a/packages/vana-sdk/src/protocol/escrow.test.ts b/packages/vana-sdk/src/protocol/escrow.test.ts index 02ada9b6..f74b8c73 100644 --- a/packages/vana-sdk/src/protocol/escrow.test.ts +++ b/packages/vana-sdk/src/protocol/escrow.test.ts @@ -4,6 +4,8 @@ import { ESCROW_DEPOSIT_ABI, GENERIC_PAYMENT_TYPES, NATIVE_ASSET_ADDRESS, + EscrowWithdrawalLifecycleError, + EscrowWithdrawalRejectionError, createEscrowGatewayClient, genericPaymentDomain, } from "./escrow"; @@ -13,9 +15,14 @@ const ACCOUNT = "0xDeAdBeEf00000000000000000000000000000001" as const; const SIG = "0xdeadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" as const; const TX_HASH = - "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" as const; -const ZERO = - "0x0000000000000000000000000000000000000000" as `0x${string}`; + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" as const; +const LIFECYCLE_TX_HASH = + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" as const; +const ZERO = "0x0000000000000000000000000000000000000000" as `0x${string}`; +const UINT256_MAX = + "115792089237316195423570985008687907853269984665640564039457584007913129639935" as const; +const UINT256_MAX_PLUS_ONE = + "115792089237316195423570985008687907853269984665640564039457584007913129639936" as const; function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { @@ -77,8 +84,9 @@ describe("createEscrowGatewayClient", () => { vi.fn().mockResolvedValue(jsonResponse(body, { status: 200 })), ); - const result = - await createEscrowGatewayClient(GATEWAY).submitDeposit({ txHash: TX_HASH }); + const result = await createEscrowGatewayClient(GATEWAY).submitDeposit({ + txHash: TX_HASH, + }); expect(result.status).toBe("finalized"); }); @@ -88,7 +96,10 @@ describe("createEscrowGatewayClient", () => { vi .fn() .mockResolvedValue( - jsonResponse({ error: "tx not found" }, { status: 404, statusText: "Not Found" }), + jsonResponse( + { error: "tx not found" }, + { status: 404, statusText: "Not Found" }, + ), ), ); @@ -145,7 +156,9 @@ describe("createEscrowGatewayClient", () => { balance: "1000000000000000000", pendingAmount: "0", authorizedAmount: "500000000000000000", + withdrawingAmount: "0", availableAmount: "500000000000000000", + withdrawalMinimumAmount: "100000000000000000", updatedAt: "2026-01-01T00:00:00.000Z", }, ], @@ -153,14 +166,16 @@ describe("createEscrowGatewayClient", () => { }; it("GETs the balance and returns the parsed body", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(jsonResponse(balanceBody)); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(balanceBody)); vi.stubGlobal("fetch", fetchMock); - const result = await createEscrowGatewayClient(GATEWAY).getEscrowBalance(ACCOUNT); + const result = + await createEscrowGatewayClient(GATEWAY).getEscrowBalance(ACCOUNT); expect(result).toEqual(balanceBody); + expect(result.balances[0]?.withdrawalMinimumAmount).toBe( + "100000000000000000", + ); expect(fetchMock).toHaveBeenCalledWith( `${GATEWAY}/v1/escrow/balance?account=${encodeURIComponent(ACCOUNT)}`, ); @@ -180,9 +195,14 @@ describe("createEscrowGatewayClient", () => { it("throws on non-2xx responses", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue( - jsonResponse({}, { status: 503, statusText: "Service Unavailable" }), - ), + vi + .fn() + .mockResolvedValue( + jsonResponse( + {}, + { status: 503, statusText: "Service Unavailable" }, + ), + ), ); await expect( @@ -202,7 +222,9 @@ describe("createEscrowGatewayClient", () => { balance: "2000000000000000000", pendingAmount: "0", authorizedAmount: "0", + withdrawingAmount: "0", availableAmount: "2000000000000000000", + withdrawalMinimumAmount: null, updatedAt: "2026-01-01T00:02:00.000Z", }, ], @@ -231,7 +253,10 @@ describe("createEscrowGatewayClient", () => { it("handles skipped sync (no pending deposits)", async () => { const skippedBody = { ...syncBody, sync: { skipped: true } }; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(skippedBody))); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse(skippedBody)), + ); const result = await createEscrowGatewayClient(GATEWAY).syncEscrowBalance(ACCOUNT); @@ -298,12 +323,14 @@ describe("createEscrowGatewayClient", () => { it("throws on 402 Insufficient Balance", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue( - jsonResponse( - { error: "insufficient balance" }, - { status: 402, statusText: "Payment Required" }, + vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: "insufficient balance" }, + { status: 402, statusText: "Payment Required" }, + ), ), - ), ); await expect( @@ -312,20 +339,386 @@ describe("createEscrowGatewayClient", () => { }); it("throws on 409 nonce replay", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: "nonce already used" }, + { status: 409, statusText: "Conflict" }, + ), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).payForOp(payParams), + ).rejects.toThrow("409"); + }); + }); + + // ---- withdraw ------------------------------------------------------------ + + describe("withdraw", () => { + const withdrawParams = { + account: ACCOUNT, + asset: ZERO, + amount: "1000000000000000000", + withdrawNonce: "4", + deadline: "1800000000", + signature: SIG, + }; + + it("POSTs the signed deadline and explicit nonce without a recipient", async () => { + const body = { + success: true as const, + status: "submitted" as const, + account: ACCOUNT, + asset: ZERO, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + txHash: TX_HASH, + message: "Withdrawal submitted; confirmation pending.", + }; + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(body, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await createEscrowGatewayClient(`${GATEWAY}/`).withdraw( + withdrawParams, + ); + + expect(result).toEqual(body); + expect(fetchMock).toHaveBeenCalledWith( + `${GATEWAY}/v1/escrow/withdraw`, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: `Web3Signed ${SIG}`, + "Content-Type": "application/json", + }), + body: JSON.stringify({ + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + }), + }), + ); + }); + + it("lets callers reconcile with the exact same signed intent", async () => { + const submitted = { + success: true as const, + status: "submitted" as const, + txHash: TX_HASH, + message: "Withdrawal submitted; confirmation pending.", + }; + const confirmed = { + success: true as const, + status: "confirmed" as const, + account: ACCOUNT, + asset: ZERO, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash: TX_HASH, + blockNumber: "123", + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(submitted, { status: 202 })) + .mockResolvedValueOnce(jsonResponse(confirmed)); + vi.stubGlobal("fetch", fetchMock); + + const client = createEscrowGatewayClient(GATEWAY); + expect(await client.withdraw(withdrawParams)).toEqual(submitted); + expect(await client.withdraw(withdrawParams)).toEqual(confirmed); + expect(confirmed.deadline).toBe(withdrawParams.deadline); + + const firstBody = (fetchMock.mock.calls[0]?.[1] as RequestInit).body; + const retryBody = (fetchMock.mock.calls[1]?.[1] as RequestInit).body; + expect(retryBody).toBe(firstBody); + expect(retryBody).toContain('"withdrawNonce":"4"'); + expect(retryBody).toContain('"deadline":"1800000000"'); + }); + + it("models a gateway no-hash 202 as a persisted, unbroadcast authorization", async () => { + const noHashSubmitted = { + success: true as const, + status: "submitted" as const, + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash: null, + message: + "Withdrawal in progress (awaiting broadcast); confirmation pending.", + }; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue(jsonResponse(noHashSubmitted, { status: 202 })), + ); + + const result = + await createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams); + + expect(result.status).toBe("submitted"); + if (result.status === "submitted" && result.txHash === null) { + expect(result.withdrawNonce).toBe(withdrawParams.withdrawNonce); + expect(result.deadline).toBe(withdrawParams.deadline); + } else { + throw new Error("expected an unbroadcast submitted withdrawal"); + } + }); + + it("surfaces deadline validation errors without changing the nonce", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( jsonResponse( - { error: "nonce already used" }, - { status: 409, statusText: "Conflict" }, + { + error: "Withdrawal authorization expired: deadline has passed", + }, + { status: 401, statusText: "Unauthorized" }, ), ), ); await expect( - createEscrowGatewayClient(GATEWAY).payForOp(payParams), - ).rejects.toThrow("409"); + createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams), + ).rejects.toThrow("deadline has passed"); }); + + it("preserves definite pre-acceptance rejection codes", async () => { + const body = { + success: false as const, + status: "rejected" as const, + code: "insufficient_available" as const, + error: "Insufficient available balance for withdrawal", + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + balance: "500", + authorizedAmount: "0", + withdrawingAmount: "0", + availableAmount: "500", + requestedAmount: withdrawParams.amount, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(body, { + status: 400, + statusText: "Bad Request", + }), + ), + ); + + try { + await createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams); + throw new Error("expected a rejection error"); + } catch (error) { + expect(error).toBeInstanceOf(EscrowWithdrawalRejectionError); + expect(error).toMatchObject({ + name: "EscrowWithdrawalRejectionError", + httpStatus: 400, + result: body, + }); + } + }); + + it.each([ + ["retryable", 503, null], + ["reorged", 409, LIFECYCLE_TX_HASH], + ["failed", 409, LIFECYCLE_TX_HASH], + ] as const)( + "preserves the %s lifecycle failure response", + async (status, httpStatus, txHash) => { + const body = { + success: false as const, + status, + error: `withdrawal is ${status}`, + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash, + blockNumber: null, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(body, { + status: httpStatus, + statusText: "Lifecycle", + }), + ), + ); + + try { + await createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams); + throw new Error("expected a lifecycle error"); + } catch (error) { + expect(error).toBeInstanceOf(EscrowWithdrawalLifecycleError); + expect(error).toMatchObject({ + name: "EscrowWithdrawalLifecycleError", + httpStatus, + result: body, + }); + } + }, + ); + + it.each(["amount", "withdrawNonce", "deadline"] as const)( + "accepts the uint256 maximum for %s", + async (field) => { + const body = { + success: false as const, + status: "failed" as const, + error: "withdrawal failed", + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash: LIFECYCLE_TX_HASH, + blockNumber: null, + [field]: UINT256_MAX, + }; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse(body, { status: 409, statusText: "Conflict" }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams), + ).rejects.toBeInstanceOf(EscrowWithdrawalLifecycleError); + }, + ); + + it.each(["amount", "withdrawNonce", "deadline"] as const)( + "rejects uint256 maximum plus one for %s", + async (field) => { + const body = { + success: false as const, + status: "failed" as const, + error: "withdrawal failed", + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash: LIFECYCLE_TX_HASH, + blockNumber: null, + [field]: UINT256_MAX_PLUS_ONE, + }; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse(body, { status: 409, statusText: "Conflict" }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams), + ).rejects.not.toBeInstanceOf(EscrowWithdrawalLifecycleError); + }, + ); + + it.each([ + ["account", "not-an-address"], + ["asset", "0x1234"], + ["amount", "invalid"], + ["withdrawNonce", "-1"], + [ + "deadline", + "0000000000000000000000000000000000000000000000000000000000000000000000000000000", + ], + ["txHash", "0x1234"], + ["blockNumber", 123], + ] as const)( + "does not treat malformed %s as a typed lifecycle failure", + async (field, value) => { + const body = { + success: false as const, + status: "failed" as const, + error: "withdrawal failed", + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash: LIFECYCLE_TX_HASH, + blockNumber: null, + [field]: value, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(body, { + status: 409, + statusText: "Conflict", + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams), + ).rejects.not.toBeInstanceOf(EscrowWithdrawalLifecycleError); + }, + ); + + it.each([ + ["retryable", null], + ["failed", LIFECYCLE_TX_HASH], + ] as const)( + "accepts %s lifecycle failures with nullable txHash %s", + async (status, txHash) => { + const body = { + success: false as const, + status, + error: `withdrawal is ${status}`, + account: withdrawParams.account, + asset: withdrawParams.asset, + amount: withdrawParams.amount, + withdrawNonce: withdrawParams.withdrawNonce, + deadline: withdrawParams.deadline, + txHash, + blockNumber: null, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(body, { + status: status === "retryable" ? 503 : 409, + statusText: "Lifecycle", + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).withdraw(withdrawParams), + ).rejects.toMatchObject({ + name: "EscrowWithdrawalLifecycleError", + result: body, + }); + }, + ); }); }); @@ -370,6 +763,257 @@ describe("NATIVE_ASSET_ADDRESS", () => { }); }); +describe("getWithdrawNonce", () => { + const nonceBody = { + success: true as const, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: "4", + }; + + it("GETs the nonce and returns the parsed body", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(nonceBody)); + vi.stubGlobal("fetch", fetchMock); + + const result = + await createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT); + + expect(result).toEqual(nonceBody); + expect(fetchMock).toHaveBeenCalledWith( + `${GATEWAY}/v1/escrow/withdraw/nonce?account=${encodeURIComponent(ACCOUNT)}`, + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("accepts null lastWithdrawNonce (first withdrawal for account)", async () => { + const bodyWithNullLastNonce = { + success: true as const, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: null, + nextWithdrawNonce: "1", + }; + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(bodyWithNullLastNonce)); + vi.stubGlobal("fetch", fetchMock); + + const result = + await createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT); + + expect(result).toEqual(bodyWithNullLastNonce); + expect(result.lastWithdrawNonce).toBeNull(); + }); + + it("validates the response structure and rejects invalid responses", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: "3", + // missing nextWithdrawNonce + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("rejects non-uint256 decimal nonce values", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: UINT256_MAX_PLUS_ONE, + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("rejects invalid account address", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: "not-an-address", + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: "4", + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("rejects non-numeric chainId", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: ACCOUNT, + chainId: "abc", + lastWithdrawNonce: "3", + nextWithdrawNonce: "4", + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("throws on non-2xx responses", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: "account not found" }, + { status: 404, statusText: "Not Found" }, + ), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("404"); + }); + + it("includes the error message from the gateway body in the thrown error", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: "gateway temporarily unavailable" }, + { status: 503, statusText: "Service Unavailable" }, + ), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("gateway temporarily unavailable"); + }); + + it("rejects response with mismatched account (case-insensitive)", async () => { + const wrongAccount = "0xdeadbeef00000000000000000000000000000002" as const; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: wrongAccount, + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: "4", + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("rejects nonce pair inconsistency: nextWithdrawNonce not lastWithdrawNonce + 1", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: "5", // Should be 4 + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("rejects when lastWithdrawNonce is null but nextWithdrawNonce is not 1", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: null, + nextWithdrawNonce: "2", // Should be 1 + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); + + it("sends fetch request with cache: 'no-store' directive", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: "4", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("rejects response with success: false even if fields otherwise match", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + success: false, + account: ACCOUNT, + chainId: "1480", + lastWithdrawNonce: "3", + nextWithdrawNonce: "4", + }), + ), + ); + + await expect( + createEscrowGatewayClient(GATEWAY).getWithdrawNonce(ACCOUNT), + ).rejects.toThrow("invalid response structure"); + }); +}); + describe("ESCROW_DEPOSIT_ABI", () => { it("exposes depositNative as payable", () => { const fn = ESCROW_DEPOSIT_ABI.find((f) => f.name === "depositNative"); diff --git a/packages/vana-sdk/src/protocol/escrow.ts b/packages/vana-sdk/src/protocol/escrow.ts index d6b2a624..d35e902a 100644 --- a/packages/vana-sdk/src/protocol/escrow.ts +++ b/packages/vana-sdk/src/protocol/escrow.ts @@ -17,7 +17,14 @@ * @module escrow */ +export { + buildWithdrawAuthorizationTypedData, + withdrawAuthorizationDomain, + WITHDRAW_AUTHORIZATION_TYPES, + type WithdrawAuthorizationMessage, +} from "./eip712"; import type { TypedDataDomain } from "viem"; +import { isHex } from "viem"; // --------------------------------------------------------------------------- // EIP-712 — GenericPayment @@ -138,15 +145,19 @@ export const NATIVE_ASSET_ADDRESS = * - `authorizedAmount` — sum of all in-flight payments authorized by * `/v1/escrow/pay` (soft-lock). May include payments not yet settled * on-chain. - * - `availableAmount` — `max(balance − authorizedAmount, 0)`. This is what - * the payer can authorize before the gateway rejects with 402. + * - `withdrawingAmount` — sum of in-flight withdrawal reservations. + * - `availableAmount` — `max(balance − authorizedAmount − withdrawingAmount, 0)`. + * This is what the account can still authorize or withdraw. */ export interface EscrowBalanceEntry { asset: string; balance: string; pendingAmount: string; authorizedAmount: string; + withdrawingAmount: string; availableAmount: string; + /** Minimum withdrawal amount currently accepted for this asset, if configured. */ + withdrawalMinimumAmount: string | null; updatedAt: string | null; } @@ -234,6 +245,115 @@ export interface EscrowPayResult { paidAt: string; } +interface EscrowWithdrawalResponseBase { + account: `0x${string}`; + asset: `0x${string}`; + amount: string; + withdrawNonce: string; + deadline: string; +} + +/** A persisted authorization whose transaction has not been broadcast yet. */ +export interface EscrowWithdrawalSubmittedWithoutTransaction extends EscrowWithdrawalResponseBase { + success: true; + status: "submitted"; + txHash: null; + message: string; +} + +/** A withdrawal with a persisted transaction that is awaiting reconciliation. */ +export interface EscrowWithdrawalSubmittedWithTransaction { + success: true; + status: "submitted"; + txHash: `0x${string}`; + message: string; + // A provisional-revert response contains only txHash, blockNumber, and + // message. Ordinary submissions include the signed-intent fields. + account?: `0x${string}`; + asset?: `0x${string}`; + amount?: string; + withdrawNonce?: string; + deadline?: string; + blockNumber?: string; +} + +/** A withdrawal that the gateway has accepted but not yet confirmed. */ +export type EscrowWithdrawalSubmittedResult = + | EscrowWithdrawalSubmittedWithoutTransaction + | EscrowWithdrawalSubmittedWithTransaction; + +/** A withdrawal whose on-chain debit has reached the named lifecycle state. */ +export interface EscrowWithdrawalSettledResult extends EscrowWithdrawalResponseBase { + success: true; + status: "confirmed" | "finalized"; + txHash: `0x${string}`; + blockNumber: string | null; +} + +/** Successful lifecycle responses from `POST /v1/escrow/withdraw`. */ +export type EscrowWithdrawalResult = + | EscrowWithdrawalSubmittedResult + | EscrowWithdrawalSettledResult; + +/** Terminal or retryable withdrawal lifecycle state returned with a non-2xx status. */ +export interface EscrowWithdrawalFailureResult extends EscrowWithdrawalResponseBase { + success: false; + status: "retryable" | "reorged" | "failed"; + error: string; + txHash: `0x${string}` | null; + blockNumber?: string | null; +} + +export type EscrowWithdrawalRejectionCode = + | "below_minimum" + | "deadline_too_far" + | "expired" + | "insufficient_available" + | "stale_nonce"; + +/** Definite pre-acceptance rejection. No durable withdrawal intent was created. */ +export interface EscrowWithdrawalRejectedResult extends EscrowWithdrawalResponseBase { + success: false; + status: "rejected"; + code: EscrowWithdrawalRejectionCode; + error: string; + balance?: string; + authorizedAmount?: string; + withdrawingAmount?: string; + availableAmount?: string; + requestedAmount?: string; + minimumAmount?: string; +} + +/** + * A typed non-2xx gateway lifecycle response. + * + * `retryable` means resend the exact signed intent. `reorged` and `failed` + * require a newly signed authorization with a new nonce. + */ +export class EscrowWithdrawalLifecycleError extends Error { + override readonly name = "EscrowWithdrawalLifecycleError"; + + constructor( + readonly httpStatus: number, + readonly result: EscrowWithdrawalFailureResult, + ) { + super(result.error); + } +} + +/** A typed non-2xx gateway rejection before a withdrawal intent is accepted. */ +export class EscrowWithdrawalRejectionError extends Error { + override readonly name = "EscrowWithdrawalRejectionError"; + + constructor( + readonly httpStatus: number, + readonly result: EscrowWithdrawalRejectedResult, + ) { + super(result.error); + } +} + /** * Parameters for submitting a deposit tx hash to the gateway. * @@ -274,6 +394,42 @@ export interface PayForOpParams { accessRecord?: EscrowAccessRecord; } +/** + * Parameters for `POST /v1/escrow/withdraw`. + * + * `withdrawNonce` and `deadline` are caller-supplied decimal uint256 strings. + * The SDK intentionally does not generate a nonce: retrying safely requires a + * durable caller-owned nonce source and the exact same signed payload. + */ +export interface WithdrawFromEscrowParams { + account: `0x${string}`; + asset: `0x${string}`; + amount: string; + withdrawNonce: string; + deadline: string; + signature: `0x${string}`; +} + +/** + * Response from `GET /v1/escrow/withdraw/nonce`. + * + * The gateway provides a read-only snapshot of the account's withdrawal nonce + * state. This is **not** a reservation; multiple concurrent callers will see + * the same `nextWithdrawNonce`. To reduce staleness risk, query immediately before + * signing/submitting the withdrawal authorization. However, `stale_nonce` errors can + * still occur under concurrent withdrawal attempts; if rejected, re-query and re-sign. + * + * Use `nextWithdrawNonce` in the signed withdrawal authorization; `lastWithdrawNonce` + * is provided for reference and diagnostics. + */ +export interface WithdrawNonceResponse { + success: true; + account: `0x${string}`; + chainId: string; + lastWithdrawNonce: string | null; + nextWithdrawNonce: string; +} + /** Wire shape of a receipt whose server signature the gateway verifies. */ export interface EscrowAccessRecord { dataPointId: `0x${string}`; @@ -330,8 +486,36 @@ export interface EscrowGatewayClient { * records the payment. Returns 402 if the payer has insufficient balance. */ payForOp(params: PayForOpParams): Promise; + + /** + * Submit or reconcile a signed withdrawal authorization. + * + * The gateway decides which signers may authorize an account. For example, + * it may accept the account itself or the confirmed owner of a registered + * app account. + * + * Retry a `submitted` result with the exact same parameters. Do not replace + * `withdrawNonce`, `deadline`, or signature unless starting a new intent. + */ + withdraw(params: WithdrawFromEscrowParams): Promise; + + /** + * Read the authoritative next withdrawal nonce for an account. + * + * The gateway is the authority on what nonce to use; use the value from + * `nextWithdrawNonce` when signing a withdrawal authorization. + * + * Do NOT generate or cache nonces client-side; concurrent callers cannot be + * safely coordinated without durable shared state. Query this endpoint immediately + * before signing/submitting to reduce staleness risk. However, `stale_nonce` errors + * can still occur; if rejected, re-query and re-sign. + */ + getWithdrawNonce(account: `0x${string}`): Promise; } +/** The only gateway capability required by direct data-access payment flows. */ +export type EscrowPaymentClient = Pick; + /** * Creates a client for the gateway escrow endpoints. * @@ -399,6 +583,31 @@ export function createEscrowGatewayClient( } } + async function throwOnWithdrawError(res: Response): Promise { + if (res.ok) return; + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new Error( + `Escrow gateway error (POST /v1/escrow/withdraw): ${res.status} ${res.statusText}`, + ); + } + + if (isEscrowWithdrawalFailureResult(body)) { + throw new EscrowWithdrawalLifecycleError(res.status, body); + } + if (isEscrowWithdrawalRejectedResult(body)) { + throw new EscrowWithdrawalRejectionError(res.status, body); + } + + const error = getGatewayErrorMessage(body); + throw new Error( + `Escrow gateway error (POST /v1/escrow/withdraw): ${res.status} ${res.statusText}${error ? `: ${error}` : ""}`, + ); + } + return { async submitDeposit({ txHash }) { const res = await fetch(`${base}/v1/escrow/deposit`, { @@ -459,5 +668,185 @@ export function createEscrowGatewayClient( await throwOnError(res, "POST /v1/escrow/pay"); return res.json() as Promise; }, + + async withdraw({ + account, + asset, + amount, + withdrawNonce, + deadline, + signature, + }) { + const res = await fetch(`${base}/v1/escrow/withdraw`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Web3Signed ${signature}`, + }, + body: JSON.stringify({ + account, + asset, + amount, + withdrawNonce, + deadline, + }), + }); + await throwOnWithdrawError(res); + return res.json() as Promise; + }, + + async getWithdrawNonce(account) { + const res = await fetch( + `${base}/v1/escrow/withdraw/nonce?account=${encodeURIComponent(account)}`, + { cache: "no-store" }, + ); + await throwOnError(res, "GET /v1/escrow/withdraw/nonce"); + const body = (await res.json()) as unknown; + if (!isWithdrawNonceResponse(body, account)) { + throw new Error( + "GET /v1/escrow/withdraw/nonce: invalid response structure", + ); + } + return body; + }, }; } + +function getGatewayErrorMessage(body: unknown): string | undefined { + if ( + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" + ) { + return body.error; + } + return undefined; +} + +function isEscrowWithdrawalFailureResult( + body: unknown, +): body is EscrowWithdrawalFailureResult { + if (typeof body !== "object" || body === null) return false; + const value = body as Record; + return ( + value.success === false && + (value.status === "retryable" || + value.status === "reorged" || + value.status === "failed") && + typeof value.error === "string" && + isAddressHex(value.account) && + isAddressHex(value.asset) && + isUint256Decimal(value.amount) && + isUint256Decimal(value.withdrawNonce) && + isUint256Decimal(value.deadline) && + (isHash(value.txHash) || value.txHash === null) && + (!("blockNumber" in value) || isBlockNumber(value.blockNumber)) + ); +} + +function isEscrowWithdrawalRejectedResult( + body: unknown, +): body is EscrowWithdrawalRejectedResult { + if (typeof body !== "object" || body === null) return false; + const value = body as Record; + return ( + value.success === false && + value.status === "rejected" && + isWithdrawalRejectionCode(value.code) && + typeof value.error === "string" && + isAddressHex(value.account) && + isAddressHex(value.asset) && + isUint256Decimal(value.amount) && + isUint256Decimal(value.withdrawNonce) && + isUint256Decimal(value.deadline) && + optionalUint256Decimal(value.balance) && + optionalUint256Decimal(value.authorizedAmount) && + optionalUint256Decimal(value.withdrawingAmount) && + optionalUint256Decimal(value.availableAmount) && + optionalUint256Decimal(value.requestedAmount) && + optionalUint256Decimal(value.minimumAmount) + ); +} + +function isWithdrawalRejectionCode( + value: unknown, +): value is EscrowWithdrawalRejectionCode { + return ( + value === "below_minimum" || + value === "deadline_too_far" || + value === "expired" || + value === "insufficient_available" || + value === "stale_nonce" + ); +} + +function optionalUint256Decimal(value: unknown): boolean { + return value === undefined || isUint256Decimal(value); +} + +function isAddressHex(value: unknown): value is `0x${string}` { + return ( + typeof value === "string" && + isHex(value, { strict: true }) && + value.length === 42 + ); +} + +function isHash(value: unknown): value is `0x${string}` { + return ( + typeof value === "string" && + isHex(value, { strict: true }) && + value.length === 66 + ); +} + +function isUint256Decimal(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || value.length > 78) { + return false; + } + if (!/^(0|[1-9]\d*)$/.test(value)) return false; + return BigInt(value) <= 2n ** 256n - 1n; +} + +function isBlockNumber(value: unknown): value is string | null { + return value === null || isUint256Decimal(value); +} + +function isWithdrawNonceResponse( + body: unknown, + requestedAccount: `0x${string}`, +): body is WithdrawNonceResponse { + if (typeof body !== "object" || body === null) return false; + const value = body as Record; + + if (value.success !== true) return false; + + if (!isAddressHex(value.account)) return false; + if (value.account.toLowerCase() !== requestedAccount.toLowerCase()) + return false; + if (typeof value.chainId !== "string") return false; + if (!/^(0|[1-9]\d*)$/.test(value.chainId)) return false; + + const isLastNull = value.lastWithdrawNonce === null; + const lastNonceValid = + isLastNull || isUint256Decimal(value.lastWithdrawNonce); + if (!lastNonceValid) return false; + + if (!isUint256Decimal(value.nextWithdrawNonce)) return false; + + // Validate nonce pair consistency: nextWithdrawNonce must be exactly lastWithdrawNonce + 1 + // or exactly 1 if lastWithdrawNonce is null + if (isLastNull) { + return value.nextWithdrawNonce === "1"; + } + + const lastNonce = BigInt(value.lastWithdrawNonce as string); + const nextNonce = BigInt(value.nextWithdrawNonce as string); + const expectedNextNonce = lastNonce + 1n; + + // Ensure no overflow (nextNonce must still be within uint256) + if (expectedNextNonce > 2n ** 256n - 1n) return false; + + return nextNonce === expectedNextNonce; +} diff --git a/packages/vana-sdk/src/protocol/gateway.test.ts b/packages/vana-sdk/src/protocol/gateway.test.ts index 8d0efd10..921d60c6 100644 --- a/packages/vana-sdk/src/protocol/gateway.test.ts +++ b/packages/vana-sdk/src/protocol/gateway.test.ts @@ -581,7 +581,8 @@ describe("createGatewayClient", () => { balance: "1000", pendingAmount: "200", authorizedAmount: "300", - availableAmount: "700", + withdrawingAmount: "100", + availableAmount: "600", updatedAt: "2026-05-08T00:00:00.000Z", }, ], @@ -590,9 +591,13 @@ describe("createGatewayClient", () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse(balanceBody)); vi.stubGlobal("fetch", fetchMock); - await expect( - createGatewayClient("https://g").getEscrowBalance("0xpayer"), - ).resolves.toEqual(balanceBody); + const balance = + await createGatewayClient("https://g").getEscrowBalance("0xpayer"); + expect(balance).toEqual(balanceBody); + expect(balance.balances[0]).toMatchObject({ + withdrawingAmount: "100", + availableAmount: "600", + }); expect(fetchMock).toHaveBeenCalledWith( "https://g/v1/escrow/balance?account=0xpayer", ); diff --git a/packages/vana-sdk/src/protocol/gateway.ts b/packages/vana-sdk/src/protocol/gateway.ts index 505d9762..d1428508 100644 --- a/packages/vana-sdk/src/protocol/gateway.ts +++ b/packages/vana-sdk/src/protocol/gateway.ts @@ -1,3 +1,13 @@ +import type { EscrowBalanceResult } from "./escrow"; + +export type { + EscrowBalanceEntry, + EscrowBalanceResult, + FailedDepositEntry as EscrowDepositFailed, + FinalizedDepositEntry as EscrowDepositFinalized, + SubmittedDepositEntry as EscrowDepositSubmitted, +} from "./escrow"; + export interface GatewayEnvelope { data: T; proof: GatewayProof; @@ -413,59 +423,11 @@ export interface SettleResult { paced?: { iterations: number }; } -// /v1/escrow/balance?account=... — pure read. Returns finalized balances by -// asset, plus the lifecycle breakdown of deposits. -export interface EscrowBalanceEntry { - asset: string; - // Gross credited deposits for (account, asset). Decremented only when the - // reconcile pass marks a payment finalized — NOT on /v1/escrow/pay. - balance: string; - // Sum of claimedAmount for deposits still in 'submitted' status — surfaced - // separately so clients don't conflate "credited" with "deposit announced - // but not yet confirmed." - pendingAmount: string; - // Sum of payments.amount for (account, asset) regardless of settled status — - // mirrors the /v1/escrow/pay handler's soft-lock counter. Subtract from - // `balance` to see how much the payer can still authorise. - authorizedAmount: string; - // `max(balance − authorizedAmount, 0)`. The headroom a payer has against - // the soft-lock before /v1/escrow/pay starts returning 402. - availableAmount: string; - updatedAt: string | null; -} - -export interface EscrowDepositSubmitted { - txHash: string; - submittedAt: string; - claimedAsset: string; - claimedAmount: string; -} - -export interface EscrowDepositFinalized { - txHash: string; - finalizedAt: string | null; - blockNumber: string | null; - claimedAsset: string; - claimedAmount: string; -} - -export interface EscrowDepositFailed { - txHash: string; - submittedAt: string; - claimedAsset: string; - claimedAmount: string; - lastError: string | null; -} - -export interface EscrowBalance { - account: string; - balances: EscrowBalanceEntry[]; - deposits: { - submitted: EscrowDepositSubmitted[]; - finalized: EscrowDepositFinalized[]; - failed: EscrowDepositFailed[]; - }; -} +/** + * Legacy `GatewayClient` name for the canonical `/v1/escrow/balance` response. + * `availableAmount` is `max(balance − authorizedAmount − withdrawingAmount, 0)`. + */ +export type EscrowBalance = EscrowBalanceResult; // /v1/escrow/deposit announces an on-chain deposit tx so the gateway can // reconcile it into the payer's balance. The gateway extracts the credited @@ -526,7 +488,7 @@ export interface GatewayClient { ): Promise; createGrant(params: CreateGrantParams): Promise<{ grantId?: string }>; revokeGrant(params: RevokeGrantParams): Promise; - getEscrowBalance(account: string): Promise; + getEscrowBalance(account: string): Promise; submitEscrowDeposit(params: SubmitDepositParams): Promise; payForOperation( params: PayForOperationParams, @@ -830,7 +792,7 @@ export function createGatewayClient(baseUrl: string): GatewayClient { } }, - async getEscrowBalance(account: string): Promise { + async getEscrowBalance(account: string): Promise { const res = await fetch(`${base}/v1/escrow/balance?account=${account}`); if (!res.ok) { throw new Error(`Gateway error: ${res.status} ${res.statusText}`); @@ -838,7 +800,7 @@ export function createGatewayClient(baseUrl: string): GatewayClient { // Unlike the rest of /v1, the balance endpoint returns the body // directly (no GatewayEnvelope wrap) — it's a pure read with no // gateway-signed attestation. See data-gateway api/v1/escrow/balance.ts. - return (await res.json()) as EscrowBalance; + return (await res.json()) as EscrowBalanceResult; }, async submitEscrowDeposit(