From 80465566870160f16214ef4ebbea5b211f4006b4 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Thu, 16 Oct 2025 17:18:44 -0600 Subject: [PATCH 01/12] prolink to spec --- packages/account-sdk/package.json | 11 +- packages/account-sdk/src/index.ts | 61 +- .../public-utilities/prolink/index.test.ts | 311 +++++++++ .../public-utilities/prolink/index.ts | 211 ++++++ .../prolink/shortcuts/generic.ts | 49 ++ .../prolink/shortcuts/sendCalls.test.ts | 259 +++++++ .../prolink/shortcuts/sendCalls.ts | 220 ++++++ .../prolink/shortcuts/sign.ts | 289 ++++++++ .../public-utilities/prolink/types.ts | 178 +++++ .../prolink/utils/base64url.test.ts | 82 +++ .../prolink/utils/base64url.ts | 80 +++ .../prolink/utils/compression.ts | 137 ++++ .../prolink/utils/encoding.test.ts | 216 ++++++ .../prolink/utils/encoding.ts | 218 ++++++ .../prolink/utils/protobuf.ts | 630 ++++++++++++++++++ .../src/ui/ProlinkDialog/ProlinkDialog.tsx | 234 +++++++ .../account-sdk/src/ui/ProlinkDialog/index.ts | 5 + yarn.lock | 200 +++++- 18 files changed, 3361 insertions(+), 30 deletions(-) create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/index.test.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/index.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/generic.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.test.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sign.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/types.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.test.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/utils/compression.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.test.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.ts create mode 100644 packages/account-sdk/src/interface/public-utilities/prolink/utils/protobuf.ts create mode 100644 packages/account-sdk/src/ui/ProlinkDialog/ProlinkDialog.tsx create mode 100644 packages/account-sdk/src/ui/ProlinkDialog/index.ts diff --git a/packages/account-sdk/package.json b/packages/account-sdk/package.json index f98794a5..0fac2ef5 100644 --- a/packages/account-sdk/package.json +++ b/packages/account-sdk/package.json @@ -84,6 +84,11 @@ "require": "./dist/interface/public-utilities/spend-permission/index.node.js" } }, + "./prolink": { + "types": "./dist/interface/public-utilities/prolink/index.d.ts", + "import": "./dist/interface/public-utilities/prolink/index.js", + "require": "./dist/interface/public-utilities/prolink/index.js" + }, "./ui-assets": { "types": "./dist/ui/assets/index.d.ts", "import": "./dist/ui/assets/index.js", @@ -116,13 +121,16 @@ "size": "size-limit" }, "dependencies": { + "@bufbuild/protobuf": "^1.7.0", "@coinbase/cdp-sdk": "^1.0.0", "@noble/hashes": "1.4.0", + "brotli-wasm": "^3.0.0", "clsx": "1.2.1", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", "ox": "0.6.9", "preact": "10.24.2", + "qrcode": "^1.5.3", "viem": "^2.31.7", "zustand": "5.0.3" }, @@ -136,6 +144,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/preact": "^3.2.4", "@types/node": "^14.18.54", + "@types/qrcode": "^1.5.5", "@vitest/coverage-v8": "2.1.2", "@vitest/web-worker": "3.2.1", "fake-indexeddb": "^6.0.0", @@ -159,4 +168,4 @@ "import": "*" } ] -} \ No newline at end of file +} diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index b95e5e8e..d221c443 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -8,34 +8,39 @@ export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js' export { PACKAGE_VERSION as VERSION } from './core/constants.js'; export { - CHAIN_IDS, - TOKENS, - base, - getPaymentStatus, - getSubscriptionStatus, - pay, - prepareCharge, - subscribe, + CHAIN_IDS, + TOKENS, + base, + getPaymentStatus, + getSubscriptionStatus, + pay, + prepareCharge, + subscribe } from './interface/payment/index.js'; export type { - ChargeOptions, - ChargeResult, - GetOrCreateSubscriptionOwnerWalletOptions, - GetOrCreateSubscriptionOwnerWalletResult, - InfoRequest, - PayerInfo, - PayerInfoResponses, - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - PaymentStatusType, - PaymentSuccess, - PrepareChargeCall, - PrepareChargeOptions, - PrepareChargeResult, - SubscriptionOptions, - SubscriptionResult, - SubscriptionStatus, - SubscriptionStatusOptions, + ChargeOptions, + ChargeResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, + InfoRequest, + PayerInfo, + PayerInfoResponses, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PaymentStatusType, + PaymentSuccess, + PrepareChargeCall, + PrepareChargeOptions, + PrepareChargeResult, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions } from './interface/payment/index.js'; + +export { decodeProlink, encodeProlink } from './interface/public-utilities/prolink/index.js'; +export type { ProlinkDecoded, ProlinkRequest } from './interface/public-utilities/prolink/index.js'; + +export { showProlinkDialog } from './ui/ProlinkDialog/index.js'; diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/index.test.ts b/packages/account-sdk/src/interface/public-utilities/prolink/index.test.ts new file mode 100644 index 00000000..1339f8a3 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/index.test.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +import { describe, expect, it } from 'vitest'; +import { decodeProlink, encodeProlink } from './index.js'; + +describe('prolink end-to-end', () => { + describe('wallet_sendCalls', () => { + it('should encode and decode ERC20 transfer', async () => { + const request = { + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + chainId: '0x2105', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40', + value: '0x0', + }, + ], + }, + ], + }; + + const encoded = await encodeProlink(request); + expect(typeof encoded).toBe('string'); + expect(encoded.length).toBeGreaterThan(0); + + const decoded = await decodeProlink(encoded); + expect(decoded.method).toBe('wallet_sendCalls'); + expect(decoded.chainId).toBe(8453); + expect(Array.isArray(decoded.params)).toBe(true); + + const params = (decoded.params as Array<{ calls: unknown[] }>)[0]; + expect(params.calls.length).toBe(1); + }); + + it('should encode and decode native transfer', async () => { + const request = { + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + chainId: '0x1', + calls: [ + { + to: '0xfe21034794a5a574b94fe4fdfd16e005f1c96e51', + data: '0x', + value: '0xde0b6b3a7640000', + }, + ], + }, + ], + }; + + const encoded = await encodeProlink(request); + const decoded = await decodeProlink(encoded); + + expect(decoded.method).toBe('wallet_sendCalls'); + expect(decoded.chainId).toBe(1); + }); + + it('should encode and decode with capabilities', async () => { + const request = { + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + chainId: '0x1', + calls: [ + { + to: '0xfe21034794a5a574b94fe4fdfd16e005f1c96e51', + data: '0x', + value: '0x100', + }, + ], + }, + ], + capabilities: { + dataCallback: { + callbackURL: 'https://example.com', + events: ['initiated'], + }, + }, + }; + + const encoded = await encodeProlink(request); + const decoded = await decodeProlink(encoded); + + expect(decoded.capabilities).toBeDefined(); + expect(decoded.capabilities?.dataCallback).toEqual({ + callbackURL: 'https://example.com', + events: ['initiated'], + }); + }); + }); + + describe('wallet_sign', () => { + it('should encode and decode SpendPermission', async () => { + const request = { + method: 'wallet_sign', + params: [ + { + version: '1', + chainId: '0x14a34', + type: '0x01', + data: { + types: { + SpendPermission: [ + { name: 'account', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'allowance', type: 'uint160' }, + { name: 'period', type: 'uint48' }, + { name: 'start', type: 'uint48' }, + { name: 'end', type: 'uint48' }, + { name: 'salt', type: 'uint256' }, + { name: 'extraData', type: 'bytes' }, + ], + }, + domain: { + name: 'Spend Permission Manager', + version: '1', + chainId: 84532, + verifyingContract: '0xf85210b21cc50302f477ba56686d2019dc9b67ad', + }, + primaryType: 'SpendPermission', + message: { + account: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spender: '0x8d9F34934dc9619e5DC3Df27D0A40b4A744E7eAa', + token: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + allowance: '0x2710', + period: 281474976710655, + start: 0, + end: 1914749767655, + salt: '0x2d6688aae9435fb91ab0a1fe7ea54ec3ffd86e8e18a0c17e1923c467dea4b75f', + extraData: '0x', + }, + }, + }, + ], + }; + + const encoded = await encodeProlink(request); + expect(typeof encoded).toBe('string'); + + const decoded = await decodeProlink(encoded); + expect(decoded.method).toBe('wallet_sign'); + expect(decoded.chainId).toBe(84532); + + const params = (decoded.params as Array<{ data: { primaryType: string } }>)[0]; + expect(params.data.primaryType).toBe('SpendPermission'); + }); + }); + + describe('generic JSON-RPC', () => { + it('should encode and decode generic method', async () => { + const request = { + method: 'eth_sendTransaction', + params: [ + { + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + value: '0x100', + data: '0x', + }, + ], + chainId: 1, + }; + + const encoded = await encodeProlink(request); + const decoded = await decodeProlink(encoded); + + expect(decoded.method).toBe('eth_sendTransaction'); + expect(decoded.chainId).toBe(1); + expect(decoded.params).toEqual(request.params); + }); + + it('should encode and decode complex params', async () => { + const request = { + method: 'custom_method', + params: { + nested: { + array: [1, 2, 3], + string: 'test', + bool: true, + }, + }, + }; + + const encoded = await encodeProlink(request); + const decoded = await decodeProlink(encoded); + + expect(decoded.method).toBe('custom_method'); + expect(decoded.params).toEqual(request.params); + }); + }); + + describe('error handling', () => { + it('should throw on invalid wallet_sendCalls params', async () => { + const request = { + method: 'wallet_sendCalls', + params: [], + }; + + await expect(encodeProlink(request)).rejects.toThrow(/requires params array/); + }); + + it('should throw on missing chainId in wallet_sendCalls', async () => { + const request = { + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + calls: [], + }, + ], + }; + + await expect(encodeProlink(request)).rejects.toThrow(/requires chainId/); + }); + + it('should throw on invalid base64url', async () => { + await expect(decodeProlink('invalid!@#$%')).rejects.toThrow(/Invalid Base64url/); + }); + + it('should throw on unsupported protocol version', async () => { + // Create a payload with protocol version 99 + // We need to encode a proper protobuf message with invalid protocol version + // Format: [compression_flag, protobuf_data] + // protobuf data: field 1 (protocol_version) = 99 + // varint encoding: field_tag = (1 << 3) | 0 = 0x08, value = 99 = 0x63 + const protobufData = new Uint8Array([0x08, 0x63]); // protocol_version = 99 + const withFlag = new Uint8Array([0x00, ...protobufData]); // No compression + const { encodeBase64url } = await import('./utils/base64url.js'); + const invalidPayload = encodeBase64url(withFlag); + + await expect(decodeProlink(invalidPayload)).rejects.toThrow(/Unsupported protocol version/); + }); + }); + + describe('compression', () => { + it('should use compression for large payloads', async () => { + const request = { + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + chainId: '0x1', + calls: Array(50) + .fill(null) + .map((_, i) => ({ + to: `0x${'1'.repeat(40)}`, + data: `0x${'ab'.repeat(100)}`, + value: '0x0', + })), + }, + ], + }; + + const encoded = await encodeProlink(request); + // Compressed payload should still be a valid base64url string + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + + // Should be able to decode successfully + const decoded = await decodeProlink(encoded); + expect(decoded.method).toBe('wallet_sendCalls'); + }); + }); + + describe('roundtrip correctness', () => { + it('should preserve all data through roundtrip', async () => { + const request = { + method: 'wallet_sendCalls', + params: [ + { + version: '2.0', + chainId: '0x2105', + from: '0x1234567890123456789012345678901234567890', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40', + value: '0x0', + }, + ], + }, + ], + capabilities: { + order_id: 'ORDER-12345', + tip_bps: 50, + }, + }; + + const encoded = await encodeProlink(request); + const decoded = await decodeProlink(encoded); + + expect(decoded.method).toBe(request.method); + expect(decoded.chainId).toBe(8453); + expect(decoded.capabilities).toEqual(request.capabilities); + + const params = (decoded.params as Array<{ version?: string; from?: string }>)[0]; + expect(params.version).toBe('2.0'); + expect(params.from?.toLowerCase()).toBe( + request.params[0].from.toLowerCase() + ); + }); + }); +}); + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/index.ts b/packages/account-sdk/src/interface/public-utilities/prolink/index.ts new file mode 100644 index 00000000..b9be390f --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/index.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Prolink URI encoding/decoding + * Implements the Compressed RPC Link Format ERC specification + */ + +import { decodeGenericRpc, encodeGenericRpc } from './shortcuts/generic.js'; +import { decodeWalletSendCalls, encodeWalletSendCalls } from './shortcuts/sendCalls.js'; +import { decodeWalletSign, encodeWalletSign } from './shortcuts/sign.js'; +import type { ProlinkDecoded, ProlinkRequest, RpcLinkPayload } from './types.js'; +import { decodeBase64url, encodeBase64url } from './utils/base64url.js'; +import { compressPayload, decompressPayload } from './utils/compression.js'; +import { decodeCapabilities, encodeCapabilities } from './utils/encoding.js'; +import { decodeRpcLinkPayload, encodeRpcLinkPayload } from './utils/protobuf.js'; + +const PROTOCOL_VERSION = 1; +const SHORTCUT_VERSION = 0; + +const SHORTCUT_GENERIC = 0; +const SHORTCUT_WALLET_SEND_CALLS = 1; +const SHORTCUT_WALLET_SIGN = 2; + +/** + * Encode a JSON-RPC request to prolink format + * @param request - JSON-RPC request with method, params, optional chainId and capabilities + * @returns Base64url-encoded prolink payload + */ +export async function encodeProlink(request: ProlinkRequest): Promise { + let payload: RpcLinkPayload; + + // Auto-detect shortcut based on method + if (request.method === 'wallet_sendCalls') { + // Validate params structure + if (!Array.isArray(request.params) || request.params.length === 0) { + throw new Error('wallet_sendCalls requires params array with at least one element'); + } + + const params = request.params[0]; + if (typeof params !== 'object' || !params) { + throw new Error('wallet_sendCalls params[0] must be an object'); + } + + // Extract chainId from params + const chainIdHex = (params as { chainId?: string }).chainId; + if (!chainIdHex) { + throw new Error('wallet_sendCalls requires chainId in params'); + } + const chainId = Number.parseInt(chainIdHex, 16); + + const walletSendCalls = encodeWalletSendCalls(params as Parameters[0]); + + payload = { + protocolVersion: PROTOCOL_VERSION, + chainId, + shortcutId: SHORTCUT_WALLET_SEND_CALLS, + shortcutVersion: SHORTCUT_VERSION, + body: { + case: 'walletSendCalls', + value: walletSendCalls, + }, + capabilities: request.capabilities ? encodeCapabilities(request.capabilities) : undefined, + }; + } else if (request.method === 'wallet_sign') { + // Validate params structure + if (!Array.isArray(request.params) || request.params.length === 0) { + throw new Error('wallet_sign requires params array with at least one element'); + } + + const params = request.params[0]; + if (typeof params !== 'object' || !params) { + throw new Error('wallet_sign params[0] must be an object'); + } + + // Extract chainId from params + const chainIdHex = (params as { chainId?: string }).chainId; + if (!chainIdHex) { + throw new Error('wallet_sign requires chainId in params'); + } + const chainId = Number.parseInt(chainIdHex, 16); + + const walletSign = encodeWalletSign(params as Parameters[0]); + + payload = { + protocolVersion: PROTOCOL_VERSION, + chainId, + shortcutId: SHORTCUT_WALLET_SIGN, + shortcutVersion: SHORTCUT_VERSION, + body: { + case: 'walletSign', + value: walletSign, + }, + capabilities: request.capabilities ? encodeCapabilities(request.capabilities) : undefined, + }; + } else { + // Generic JSON-RPC + const generic = encodeGenericRpc(request.method, request.params); + + payload = { + protocolVersion: PROTOCOL_VERSION, + chainId: request.chainId, + shortcutId: SHORTCUT_GENERIC, + shortcutVersion: SHORTCUT_VERSION, + body: { + case: 'generic', + value: generic, + }, + capabilities: request.capabilities ? encodeCapabilities(request.capabilities) : undefined, + }; + } + + // Serialize to protobuf + const protoBytes = encodeRpcLinkPayload(payload); + + // Compress (with flag byte) + const { compressed, flag } = await compressPayload(protoBytes); + const withFlag = new Uint8Array(compressed.length + 1); + withFlag[0] = flag; + withFlag.set(compressed, 1); + + // Base64url encode + return encodeBase64url(withFlag); +} + +/** + * Decode a prolink payload to JSON-RPC request + * @param payload - Base64url-encoded prolink payload + * @returns Decoded JSON-RPC request + */ +export async function decodeProlink(payload: string): Promise { + // Base64url decode + const bytes = decodeBase64url(payload); + + // Decompress + const decompressed = await decompressPayload(bytes); + + // Deserialize protobuf + const rpcPayload = decodeRpcLinkPayload(decompressed); + + // Validate protocol version + if (rpcPayload.protocolVersion !== PROTOCOL_VERSION) { + throw new Error( + `Unsupported protocol version: ${rpcPayload.protocolVersion} (expected ${PROTOCOL_VERSION})` + ); + } + + // Decode capabilities + const capabilities = rpcPayload.capabilities + ? decodeCapabilities(rpcPayload.capabilities) + : undefined; + + // Dispatch to shortcut decoder + if (rpcPayload.shortcutId === SHORTCUT_GENERIC) { + if (rpcPayload.body.case !== 'generic') { + throw new Error('Invalid payload: shortcut 0 requires generic body'); + } + + const { method, params } = decodeGenericRpc(rpcPayload.body.value); + + return { + method, + params, + chainId: rpcPayload.chainId, + capabilities, + }; + } + + if (rpcPayload.shortcutId === SHORTCUT_WALLET_SEND_CALLS) { + if (rpcPayload.body.case !== 'walletSendCalls') { + throw new Error('Invalid payload: shortcut 1 requires walletSendCalls body'); + } + + if (!rpcPayload.chainId) { + throw new Error('wallet_sendCalls requires chainId'); + } + + const params = decodeWalletSendCalls(rpcPayload.body.value, rpcPayload.chainId); + + return { + method: 'wallet_sendCalls', + params: [params], + chainId: rpcPayload.chainId, + capabilities, + }; + } + + if (rpcPayload.shortcutId === SHORTCUT_WALLET_SIGN) { + if (rpcPayload.body.case !== 'walletSign') { + throw new Error('Invalid payload: shortcut 2 requires walletSign body'); + } + + if (!rpcPayload.chainId) { + throw new Error('wallet_sign requires chainId'); + } + + const params = decodeWalletSign(rpcPayload.body.value, rpcPayload.chainId); + + return { + method: 'wallet_sign', + params: [params], + chainId: rpcPayload.chainId, + capabilities, + }; + } + + throw new Error(`Unsupported shortcut ID: ${rpcPayload.shortcutId}`); +} + +// Re-export types +export type { ProlinkDecoded, ProlinkRequest } from './types.js'; + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/generic.ts b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/generic.ts new file mode 100644 index 00000000..e0095adc --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/generic.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Shortcut 0: Generic JSON-RPC encoder/decoder + * Universal fallback for any JSON-RPC method + */ + +import type { GenericJsonRpc } from '../types.js'; + +/** + * Encode a generic JSON-RPC request + * @param method - JSON-RPC method name + * @param params - Parameters (any JSON-serializable value) + * @returns GenericJsonRpc message + */ +export function encodeGenericRpc(method: string, params: unknown): GenericJsonRpc { + const paramsJson = JSON.stringify(params); + const paramsBytes = new TextEncoder().encode(paramsJson); + + return { + method, + paramsJson: paramsBytes, + rpcVersion: '2.0', + }; +} + +/** + * Decode a generic JSON-RPC request + * @param payload - GenericJsonRpc message + * @returns Decoded method and params + */ +export function decodeGenericRpc(payload: GenericJsonRpc): { method: string; params: unknown } { + const paramsJson = new TextDecoder().decode(payload.paramsJson); + + let params: unknown; + try { + params = JSON.parse(paramsJson); + } catch (error) { + throw new Error( + `Failed to parse params JSON: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + + return { + method: payload.method, + params, + }; +} + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.test.ts b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.test.ts new file mode 100644 index 00000000..aff4ac4e --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +import { describe, expect, it } from 'vitest'; +import { SendCallsType } from '../types.js'; +import { decodeWalletSendCalls, encodeWalletSendCalls } from './sendCalls.js'; + +describe('sendCalls shortcut', () => { + describe('ERC20 transfer detection', () => { + it('should detect ERC20 transfer', () => { + const params = { + version: '1.0', + chainId: '0x2105', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40', + value: '0x0', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + + expect(encoded.type).toBe(SendCallsType.ERC20_TRANSFER); + expect(encoded.transactionData.case).toBe('erc20Transfer'); + + if (encoded.transactionData.case === 'erc20Transfer') { + const { token, recipient, amount } = encoded.transactionData.value; + expect(token.length).toBe(20); + expect(recipient.length).toBe(20); + expect(amount).toEqual(new Uint8Array([0x4c, 0x4b, 0x40])); + } + }); + + it('should NOT detect ERC20 if multiple calls', () => { + const params = { + chainId: '0x1', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40', + value: '0x0', + }, + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0x', + value: '0x0', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + expect(encoded.type).toBe(SendCallsType.GENERIC_CALLS); + }); + + it('should NOT detect ERC20 if non-zero value', () => { + const params = { + chainId: '0x1', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40', + value: '0x1', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + expect(encoded.type).toBe(SendCallsType.GENERIC_CALLS); + }); + + it('should NOT detect ERC20 if wrong data length', () => { + const params = { + chainId: '0x1', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb0000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e51', + value: '0x0', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + expect(encoded.type).toBe(SendCallsType.GENERIC_CALLS); + }); + }); + + describe('Native transfer detection', () => { + it('should detect native transfer', () => { + const params = { + version: '1.0', + chainId: '0x1', + calls: [ + { + to: '0xfe21034794a5a574b94fe4fdfd16e005f1c96e51', + data: '0x', + value: '0xde0b6b3a7640000', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + + expect(encoded.type).toBe(SendCallsType.NATIVE_TRANSFER); + expect(encoded.transactionData.case).toBe('nativeTransfer'); + + if (encoded.transactionData.case === 'nativeTransfer') { + const { recipient, amount } = encoded.transactionData.value; + expect(recipient.length).toBe(20); + expect(amount).toEqual(new Uint8Array([0x0d, 0xe0, 0xb6, 0xb3, 0xa7, 0x64, 0x00, 0x00])); + } + }); + + it('should NOT detect native if has data', () => { + const params = { + chainId: '0x1', + calls: [ + { + to: '0xfe21034794a5a574b94fe4fdfd16e005f1c96e51', + data: '0x1234', + value: '0xde0b6b3a7640000', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + expect(encoded.type).toBe(SendCallsType.GENERIC_CALLS); + }); + + it('should NOT detect native if zero value', () => { + const params = { + chainId: '0x1', + calls: [ + { + to: '0xfe21034794a5a574b94fe4fdfd16e005f1c96e51', + data: '0x', + value: '0x0', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + expect(encoded.type).toBe(SendCallsType.GENERIC_CALLS); + }); + }); + + describe('Generic calls', () => { + it('should encode multiple calls as generic', () => { + const params = { + chainId: '0x1', + calls: [ + { + to: '0x1111111111111111111111111111111111111111', + data: '0x1234', + value: '0x0', + }, + { + to: '0x2222222222222222222222222222222222222222', + data: '0x5678', + value: '0x100', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + + expect(encoded.type).toBe(SendCallsType.GENERIC_CALLS); + expect(encoded.transactionData.case).toBe('genericCalls'); + + if (encoded.transactionData.case === 'genericCalls') { + expect(encoded.transactionData.value.calls.length).toBe(2); + } + }); + }); + + describe('ERC20 roundtrip', () => { + it('should roundtrip ERC20 transfer', () => { + const params = { + version: '1.0', + chainId: '0x2105', + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40', + value: '0x0', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + const decoded = decodeWalletSendCalls(encoded, 8453); + + expect(decoded.chainId).toBe(params.chainId); + expect(decoded.version).toBe(params.version); + expect(decoded.calls.length).toBe(1); + + // Normalize addresses for comparison + expect(decoded.calls[0].to.toLowerCase()).toBe(params.calls[0].to.toLowerCase()); + expect(decoded.calls[0].data.toLowerCase()).toBe(params.calls[0].data.toLowerCase()); + expect(decoded.calls[0].value).toBe(params.calls[0].value); + }); + }); + + describe('Native transfer roundtrip', () => { + it('should roundtrip native transfer', () => { + const params = { + version: '1.0', + chainId: '0x1', + calls: [ + { + to: '0xfe21034794a5a574b94fe4fdfd16e005f1c96e51', + data: '0x', + value: '0xde0b6b3a7640000', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + const decoded = decodeWalletSendCalls(encoded, 1); + + expect(decoded.chainId).toBe(params.chainId); + expect(decoded.version).toBe(params.version); + expect(decoded.calls.length).toBe(1); + expect(decoded.calls[0].to.toLowerCase()).toBe(params.calls[0].to.toLowerCase()); + expect(decoded.calls[0].data).toBe(params.calls[0].data); + expect(decoded.calls[0].value.toLowerCase()).toBe(params.calls[0].value.toLowerCase()); + }); + }); + + describe('Generic calls roundtrip', () => { + it('should roundtrip generic calls', () => { + const params = { + version: '1.0', + chainId: '0x1', + calls: [ + { + to: '0x1111111111111111111111111111111111111111', + data: '0x1234', + value: '0x0', + }, + { + to: '0x2222222222222222222222222222222222222222', + data: '0x5678', + value: '0x100', + }, + ], + }; + + const encoded = encodeWalletSendCalls(params); + const decoded = decodeWalletSendCalls(encoded, 1); + + expect(decoded.calls.length).toBe(2); + expect(decoded.calls[0].to.toLowerCase()).toBe(params.calls[0].to.toLowerCase()); + expect(decoded.calls[1].to.toLowerCase()).toBe(params.calls[1].to.toLowerCase()); + }); + }); +}); + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.ts b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.ts new file mode 100644 index 00000000..16ab3e42 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sendCalls.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Shortcut 1: wallet_sendCalls encoder/decoder + * Optimizes EIP-5792 wallet_sendCalls requests + */ + +import type { WalletSendCalls } from '../types.js'; +import { SendCallsType } from '../types.js'; +import { + bytesToHex, + decodeAddress, + encodeAddress, + encodeAmount, + hexToBytes, + pad32 +} from '../utils/encoding.js'; + +// ERC20 transfer(address,uint256) selector +const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; + +type SendCallsParams = { + version?: string; + chainId: string; + from?: string; + calls: Array<{ + to: string; + data?: string; + value?: string; + }>; + capabilities?: Record; +}; + +/** + * Detect if calls represent an ERC20 transfer + * Must be exactly one call with transfer selector, 68-byte data, and zero value + */ +function detectErc20Transfer( + calls: SendCallsParams['calls'] +): { token: string; recipient: string; amount: bigint } | null { + if (calls.length !== 1) return null; + + const call = calls[0]; + const data = call.data || '0x'; + const value = call.value || '0x0'; + + // Check for zero value + const valueAmount = BigInt(value); + if (valueAmount !== 0n) return null; + + // Check selector and length (4 bytes selector + 32 bytes recipient + 32 bytes amount = 68 bytes = 136 hex chars) + if (!data.toLowerCase().startsWith(ERC20_TRANSFER_SELECTOR.toLowerCase())) return null; + + // Remove 0x prefix for length check + const dataNoPrefix = data.slice(2); + if (dataNoPrefix.length !== 136) return null; // 68 bytes = 136 hex chars + + // Extract recipient (bytes 4-35, i.e., chars 8-71) + const recipientPadded = dataNoPrefix.slice(8, 72); + const recipient = '0x' + recipientPadded.slice(24); // Last 20 bytes (40 chars) + + // Extract amount (bytes 36-67, i.e., chars 72-135) + const amountHex = dataNoPrefix.slice(72, 136); + const amount = BigInt('0x' + amountHex); + + return { + token: call.to, + recipient, + amount, + }; +} + +/** + * Detect if calls represent a native transfer + * Must be exactly one call with empty data and non-zero value + */ +function detectNativeTransfer( + calls: SendCallsParams['calls'] +): { recipient: string; amount: bigint } | null { + if (calls.length !== 1) return null; + + const call = calls[0]; + const data = call.data || '0x'; + const value = call.value || '0x0'; + + // Check for empty data + if (data !== '0x' && data !== '') return null; + + // Check for non-zero value + const amount = BigInt(value); + if (amount === 0n) return null; + + return { + recipient: call.to, + amount, + }; +} + +/** + * Encode wallet_sendCalls request + * @param params - EIP-5792 wallet_sendCalls parameters + * @returns WalletSendCalls message + */ +export function encodeWalletSendCalls(params: SendCallsParams): WalletSendCalls { + // Detect transaction type (order matters per spec) + const erc20 = detectErc20Transfer(params.calls); + if (erc20) { + return { + type: SendCallsType.ERC20_TRANSFER, + transactionData: { + case: 'erc20Transfer', + value: { + token: encodeAddress(erc20.token), + recipient: encodeAddress(erc20.recipient), + amount: encodeAmount(erc20.amount), + }, + }, + from: params.from ? encodeAddress(params.from) : undefined, + version: params.version || '1.0', + }; + } + + const native = detectNativeTransfer(params.calls); + if (native) { + return { + type: SendCallsType.NATIVE_TRANSFER, + transactionData: { + case: 'nativeTransfer', + value: { + recipient: encodeAddress(native.recipient), + amount: encodeAmount(native.amount), + }, + }, + from: params.from ? encodeAddress(params.from) : undefined, + version: params.version || '1.0', + }; + } + + // Generic calls + return { + type: SendCallsType.GENERIC_CALLS, + transactionData: { + case: 'genericCalls', + value: { + calls: params.calls.map((call) => ({ + to: encodeAddress(call.to), + data: call.data ? hexToBytes(call.data) : new Uint8Array(), + value: encodeAmount(call.value || '0x0'), + })), + }, + }, + from: params.from ? encodeAddress(params.from) : undefined, + version: params.version || '1.0', + }; +} + +/** + * Decode wallet_sendCalls request + * @param payload - WalletSendCalls message + * @param chainId - Chain ID from top-level payload + * @returns EIP-5792 wallet_sendCalls parameters + */ +export function decodeWalletSendCalls( + payload: WalletSendCalls, + chainId: number +): SendCallsParams { + const result: SendCallsParams = { + version: payload.version || '1.0', + chainId: `0x${chainId.toString(16)}`, + from: payload.from ? decodeAddress(payload.from) : undefined, + calls: [], + }; + + if (payload.transactionData.case === 'erc20Transfer') { + const { token, recipient, amount } = payload.transactionData.value; + + // Reconstruct ERC20 transfer call data with proper 32-byte padding + const recipientPadded = pad32(recipient); + const amountPadded = pad32(amount.length > 0 ? amount : new Uint8Array([0])); + + // Convert to hex without minimal encoding (keep all padding) + const recipientHex = Array.from(recipientPadded) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + const amountHex = Array.from(amountPadded) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + const data = ERC20_TRANSFER_SELECTOR + recipientHex + amountHex; + + result.calls = [ + { + to: decodeAddress(token), + data, + value: '0x0', + }, + ]; + } else if (payload.transactionData.case === 'nativeTransfer') { + const { recipient, amount } = payload.transactionData.value; + + result.calls = [ + { + to: decodeAddress(recipient), + data: '0x', + value: bytesToHex(amount.length > 0 ? amount : new Uint8Array([0])), + }, + ]; + } else if (payload.transactionData.case === 'genericCalls') { + const { calls } = payload.transactionData.value; + + result.calls = calls.map((call) => ({ + to: decodeAddress(call.to), + data: call.data.length > 0 ? bytesToHex(call.data) : '0x', + value: bytesToHex(call.value.length > 0 ? call.value : new Uint8Array([0])), + })); + } + + return result; +} + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sign.ts b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sign.ts new file mode 100644 index 00000000..4c36f833 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/shortcuts/sign.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Shortcut 2: wallet_sign encoder/decoder + * Optimizes EIP-7871 wallet_sign requests with EIP-712 typed data + */ + +import type { WalletSign } from '../types.js'; +import { SignType } from '../types.js'; +import { + bytesToHex, + decodeAddress, + encodeAddress, + encodeAmount, + hexToBytes +} from '../utils/encoding.js'; + +type TypedData = { + types: Record>; + domain: { + name?: string; + version?: string; + chainId?: number | string; + verifyingContract?: string; + }; + primaryType: string; + message: Record; +}; + +type WalletSignParams = { + version?: string; + chainId: string; + type?: string | number; + data: TypedData; + capabilities?: Record; +}; + +/** + * Normalize type field to canonical EIP-712 indicator + */ +function normalizeType(type?: string | number): string { + if (type === undefined) return '0x01'; + if (typeof type === 'number') return `0x${type.toString(16)}`; + if (type === '0x1') return '0x01'; + return type; +} + +/** + * Detect if typed data is a SpendPermission + */ +function detectSpendPermission(typedData: TypedData): boolean { + return ( + typedData.primaryType === 'SpendPermission' && + typedData.domain.verifyingContract !== undefined + ); +} + +/** + * Detect if typed data is a ReceiveWithAuthorization + */ +function detectReceiveWithAuthorization(typedData: TypedData): boolean { + return ( + typedData.primaryType === 'ReceiveWithAuthorization' && + typedData.domain.verifyingContract !== undefined + ); +} + +/** + * Encode wallet_sign request + * @param params - EIP-7871 wallet_sign parameters + * @returns WalletSign message + */ +export function encodeWalletSign(params: WalletSignParams): WalletSign { + const normalizedType = normalizeType(params.type); + + // Validate it's EIP-712 + if (normalizedType !== '0x01') { + throw new Error(`Unsupported sign type for prolink encoding: ${normalizedType}`); + } + + // Validate chain ID consistency + const paramsChainId = BigInt(params.chainId); + const domainChainId = + typeof params.data.domain.chainId === 'string' + ? BigInt(params.data.domain.chainId) + : BigInt(params.data.domain.chainId || 0); + + if (paramsChainId !== domainChainId) { + throw new Error( + `Chain ID mismatch: params has ${paramsChainId}, domain has ${domainChainId}` + ); + } + + // Detect signature type + if (detectSpendPermission(params.data)) { + const msg = params.data.message; + + return { + type: SignType.SPEND_PERMISSION, + signatureData: { + case: 'spendPermission', + value: { + account: encodeAddress(msg.account as string), + spender: encodeAddress(msg.spender as string), + token: encodeAddress(msg.token as string), + allowance: encodeAmount(msg.allowance as string | bigint), + period: BigInt(msg.period as number | bigint), + start: BigInt(msg.start as number | bigint), + end: BigInt(msg.end as number | bigint), + salt: hexToBytes(msg.salt as string), + extraData: + !msg.extraData || msg.extraData === '0x' + ? new Uint8Array() + : hexToBytes(msg.extraData as string), + verifyingContract: encodeAddress(params.data.domain.verifyingContract!), + domainName: params.data.domain.name || '', + domainVersion: params.data.domain.version || '', + }, + }, + version: params.version || '1', + }; + } + + if (detectReceiveWithAuthorization(params.data)) { + const msg = params.data.message; + + return { + type: SignType.RECEIVE_WITH_AUTHORIZATION, + signatureData: { + case: 'receiveWithAuthorization', + value: { + from: encodeAddress(msg.from as string), + to: encodeAddress(msg.to as string), + value: encodeAmount(msg.value as string | bigint), + validAfter: encodeAmount(msg.validAfter as string | bigint), + validBefore: encodeAmount(msg.validBefore as string | bigint), + nonce: hexToBytes(msg.nonce as string), + verifyingContract: encodeAddress(params.data.domain.verifyingContract!), + domainName: params.data.domain.name || '', + domainVersion: params.data.domain.version || '', + }, + }, + version: params.version || '1', + }; + } + + // Generic typed data + const typedDataJson = JSON.stringify(params.data); + const typedDataBytes = new TextEncoder().encode(typedDataJson); + + return { + type: SignType.GENERIC_TYPED_DATA, + signatureData: { + case: 'genericTypedData', + value: { + typedDataJson: typedDataBytes, + }, + }, + version: params.version || '1', + }; +} + +/** + * Decode wallet_sign request + * @param payload - WalletSign message + * @param chainId - Chain ID from top-level payload + * @returns EIP-7871 wallet_sign parameters + */ +export function decodeWalletSign(payload: WalletSign, chainId: number): WalletSignParams { + if (payload.signatureData.case === 'spendPermission') { + const sp = payload.signatureData.value; + + const typedData: TypedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + SpendPermission: [ + { name: 'account', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'allowance', type: 'uint160' }, + { name: 'period', type: 'uint48' }, + { name: 'start', type: 'uint48' }, + { name: 'end', type: 'uint48' }, + { name: 'salt', type: 'uint256' }, + { name: 'extraData', type: 'bytes' }, + ], + }, + domain: { + name: sp.domainName, + version: sp.domainVersion, + chainId, + verifyingContract: decodeAddress(sp.verifyingContract), + }, + primaryType: 'SpendPermission', + message: { + account: decodeAddress(sp.account), + spender: decodeAddress(sp.spender), + token: decodeAddress(sp.token), + allowance: bytesToHex(sp.allowance.length > 0 ? sp.allowance : new Uint8Array([0])), + period: Number(sp.period), + start: Number(sp.start), + end: Number(sp.end), + salt: bytesToHex(sp.salt), + extraData: sp.extraData.length > 0 ? bytesToHex(sp.extraData) : '0x', + }, + }; + + return { + version: payload.version || '1', + chainId: `0x${chainId.toString(16)}`, + type: '0x01', + data: typedData, + }; + } + + if (payload.signatureData.case === 'receiveWithAuthorization') { + const rwa = payload.signatureData.value; + + const typedData: TypedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + ReceiveWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + }, + domain: { + name: rwa.domainName, + version: rwa.domainVersion, + chainId, + verifyingContract: decodeAddress(rwa.verifyingContract), + }, + primaryType: 'ReceiveWithAuthorization', + message: { + from: decodeAddress(rwa.from), + to: decodeAddress(rwa.to), + value: bytesToHex(rwa.value.length > 0 ? rwa.value : new Uint8Array([0])), + validAfter: bytesToHex(rwa.validAfter.length > 0 ? rwa.validAfter : new Uint8Array([0])), + validBefore: bytesToHex(rwa.validBefore.length > 0 ? rwa.validBefore : new Uint8Array([0])), + nonce: bytesToHex(rwa.nonce), + }, + }; + + return { + version: payload.version || '1', + chainId: `0x${chainId.toString(16)}`, + type: '0x01', + data: typedData, + }; + } + + if (payload.signatureData.case === 'genericTypedData') { + const gtd = payload.signatureData.value; + const typedDataJson = new TextDecoder().decode(gtd.typedDataJson); + + let typedData: TypedData; + try { + typedData = JSON.parse(typedDataJson); + } catch (error) { + throw new Error( + `Failed to parse typed data JSON: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + + return { + version: payload.version || '1', + chainId: `0x${chainId.toString(16)}`, + type: '0x01', + data: typedData, + }; + } + + throw new Error('Unknown signature data type'); +} + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/types.ts b/packages/account-sdk/src/interface/public-utilities/prolink/types.ts new file mode 100644 index 00000000..21fa6f45 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/types.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Protocol Buffers message types for Prolink encoding + * Based on the Compressed RPC Link Format ERC specification + */ + +/** + * Transaction type discriminator for wallet_sendCalls + */ +export enum SendCallsType { + SEND_CALLS_UNKNOWN = 0, + ERC20_TRANSFER = 1, + NATIVE_TRANSFER = 2, + GENERIC_CALLS = 3, +} + +/** + * Signature type discriminator for wallet_sign + */ +export enum SignType { + SIGN_UNKNOWN = 0, + SPEND_PERMISSION = 1, + RECEIVE_WITH_AUTHORIZATION = 2, + GENERIC_TYPED_DATA = 3, +} + +/** + * ERC20 transfer data + */ +export type Erc20Transfer = { + token: Uint8Array; // 20-byte ERC20 token contract address + recipient: Uint8Array; // 20-byte recipient address + amount: Uint8Array; // Amount in token's smallest unit (big-endian bytes, minimal encoding) +}; + +/** + * Native transfer data + */ +export type NativeTransfer = { + recipient: Uint8Array; // 20-byte recipient address + amount: Uint8Array; // Amount in wei (big-endian bytes, minimal encoding) +}; + +/** + * Generic call data + */ +export type Call = { + to: Uint8Array; // 20-byte contract/EOA address + data: Uint8Array; // Calldata (may be empty) + value: Uint8Array; // Value in wei (big-endian bytes, minimal encoding) +}; + +/** + * Generic calls data + */ +export type GenericCalls = { + calls: Call[]; +}; + +/** + * wallet_sendCalls message + */ +export type WalletSendCalls = { + type: SendCallsType; + transactionData: + | { case: 'erc20Transfer'; value: Erc20Transfer } + | { case: 'nativeTransfer'; value: NativeTransfer } + | { case: 'genericCalls'; value: GenericCalls } + | { case: undefined; value?: undefined }; + from?: Uint8Array; // 20-byte address (optional) + version?: string; // RPC version (e.g., "1.0") +}; + +/** + * Spend permission data + */ +export type SpendPermission = { + // EIP-712 message fields + account: Uint8Array; // 20-byte account address + spender: Uint8Array; // 20-byte spender address + token: Uint8Array; // 20-byte token address + allowance: Uint8Array; // uint160 (big-endian bytes, minimal encoding) + period: bigint; // uint48 + start: bigint; // uint48 + end: bigint; // uint48 + salt: Uint8Array; // 32-byte salt + extraData: Uint8Array; // extraData (may be empty for "0x") + + // EIP-712 domain fields + verifyingContract: Uint8Array; // 20-byte verifyingContract + domainName: string; // Domain name + domainVersion: string; // Domain version +}; + +/** + * Receive with authorization data + */ +export type ReceiveWithAuthorization = { + // EIP-712 message fields + from: Uint8Array; // 20-byte from address + to: Uint8Array; // 20-byte to address + value: Uint8Array; // uint256 (big-endian bytes, minimal encoding) + validAfter: Uint8Array; // uint256 (typically 0) + validBefore: Uint8Array; // uint256 (timestamp) + nonce: Uint8Array; // bytes32 + + // EIP-712 domain fields + verifyingContract: Uint8Array; // 20-byte USDC contract + domainName: string; // Domain name + domainVersion: string; // Domain version +}; + +/** + * Generic typed data + */ +export type GenericTypedData = { + typedDataJson: Uint8Array; // UTF-8 JSON-encoded EIP-712 TypedData +}; + +/** + * wallet_sign message + */ +export type WalletSign = { + type: SignType; + signatureData: + | { case: 'spendPermission'; value: SpendPermission } + | { case: 'receiveWithAuthorization'; value: ReceiveWithAuthorization } + | { case: 'genericTypedData'; value: GenericTypedData } + | { case: undefined; value?: undefined }; + version?: string; // RPC version +}; + +/** + * Generic JSON-RPC message + */ +export type GenericJsonRpc = { + method: string; // JSON-RPC method name + paramsJson: Uint8Array; // UTF-8 JSON-encoded params + rpcVersion?: string; // Optional JSON-RPC version +}; + +/** + * Core RPC link payload + */ +export type RpcLinkPayload = { + protocolVersion: number; // Core payload version (1) + chainId?: number; // Canonical numeric chain ID + shortcutId: number; // 0 = GENERIC_JSON_RPC, 1 = WALLET_SEND_CALLS, 2 = WALLET_SIGN + shortcutVersion: number; // Shortcut-specific version + body: + | { case: 'generic'; value: GenericJsonRpc } + | { case: 'walletSendCalls'; value: WalletSendCalls } + | { case: 'walletSign'; value: WalletSign } + | { case: undefined; value?: undefined }; + capabilities?: Map; // Extension point for metadata +}; + +/** + * High-level request type for encoding + */ +export type ProlinkRequest = { + method: string; + params: unknown; + chainId?: number; + capabilities?: Record; +}; + +/** + * High-level decoded type + */ +export type ProlinkDecoded = { + method: string; + params: unknown; + chainId?: number; + capabilities?: Record; +}; + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.test.ts b/packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.test.ts new file mode 100644 index 00000000..d816eb77 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +import { describe, expect, it } from 'vitest'; +import { decodeBase64url, encodeBase64url } from './base64url.js'; + +describe('base64url', () => { + describe('encodeBase64url', () => { + it('should encode empty array', () => { + const result = encodeBase64url(new Uint8Array([])); + expect(result).toBe(''); + }); + + it('should encode single byte', () => { + const result = encodeBase64url(new Uint8Array([0x00])); + expect(result).toBe('AA'); + }); + + it('should encode without padding', () => { + const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" + const result = encodeBase64url(data); + expect(result).not.toContain('='); + expect(result).toBe('SGVsbG8'); + }); + + it('should use URL-safe characters', () => { + // This input would produce + and / in standard base64 + const data = new Uint8Array([0xfb, 0xff, 0xfe]); + const result = encodeBase64url(data); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).toBe('-__-'); // + becomes -, / becomes _ + }); + + it('should roundtrip correctly', () => { + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const encoded = encodeBase64url(data); + const decoded = decodeBase64url(encoded); + expect(decoded).toEqual(data); + }); + }); + + describe('decodeBase64url', () => { + it('should decode empty string', () => { + const result = decodeBase64url(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should decode without padding', () => { + const result = decodeBase64url('SGVsbG8'); + expect(result).toEqual(new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])); + }); + + it('should decode with padding', () => { + const result = decodeBase64url('SGVsbG8='); + expect(result).toEqual(new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])); + }); + + it('should decode URL-safe characters', () => { + const result = decodeBase64url('-__-'); + expect(result).toEqual(new Uint8Array([0xfb, 0xff, 0xfe])); + }); + + it('should throw on invalid characters', () => { + expect(() => decodeBase64url('ABC@DEF')).toThrow(/Invalid Base64url character/); + expect(() => decodeBase64url('ABC DEF')).toThrow(/Invalid Base64url character/); + expect(() => decodeBase64url('ABC+DEF')).toThrow(/Invalid Base64url character/); + }); + + it('should throw on invalid padding', () => { + expect(() => decodeBase64url('ABC=DEF')).toThrow(/Invalid Base64url/); + expect(() => decodeBase64url('ABCD===')).toThrow(/Invalid Base64url padding/); + }); + + it('should handle long strings', () => { + const data = new Uint8Array(1000).map((_, i) => i % 256); + const encoded = encodeBase64url(data); + const decoded = decodeBase64url(encoded); + expect(decoded).toEqual(data); + }); + }); +}); + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.ts b/packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.ts new file mode 100644 index 00000000..07e7667e --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/utils/base64url.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Base64url encoding/decoding utilities + * RFC 4648 compliant + */ + +const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +/** + * Encode a Uint8Array to Base64url without padding + * @param data - Data to encode + * @returns Base64url encoded string without padding + */ +export function encodeBase64url(data: Uint8Array): string { + // Use browser's built-in btoa for base64 encoding + let binary = ''; + for (let i = 0; i < data.length; i++) { + binary += String.fromCharCode(data[i]); + } + const base64 = btoa(binary); + + // Convert base64 to base64url (replace + with -, / with _) + // Remove padding (=) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Decode a Base64url string to Uint8Array + * Accepts strings with or without padding + * @param payload - Base64url encoded string + * @returns Decoded Uint8Array + * @throws Error if payload contains invalid characters + */ +export function decodeBase64url(payload: string): Uint8Array { + // Validate characters + for (let i = 0; i < payload.length; i++) { + const char = payload[i]; + if (!BASE64URL_ALPHABET.includes(char) && char !== '=') { + throw new Error( + `Invalid Base64url character at position ${i}: '${char}'. Only A-Z, a-z, 0-9, -, _ are allowed.` + ); + } + } + + // Validate padding (must be at end only) + const paddingIndex = payload.indexOf('='); + if (paddingIndex !== -1) { + const paddingPart = payload.slice(paddingIndex); + if (paddingPart !== '=' && paddingPart !== '==') { + throw new Error('Invalid Base64url padding'); + } + // Ensure no non-padding characters after padding + for (let i = paddingIndex; i < payload.length; i++) { + if (payload[i] !== '=') { + throw new Error('Invalid Base64url: characters found after padding'); + } + } + } + + // Convert base64url to standard base64 + let base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + + // Add padding if needed (base64 requires padding for decoding) + const paddingNeeded = (4 - (base64.length % 4)) % 4; + base64 += '='.repeat(paddingNeeded); + + try { + // Use browser's built-in atob for base64 decoding + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } catch (error) { + throw new Error(`Failed to decode Base64url: ${error instanceof Error ? error.message : 'unknown error'}`); + } +} + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/utils/compression.ts b/packages/account-sdk/src/interface/public-utilities/prolink/utils/compression.ts new file mode 100644 index 00000000..d8317505 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/utils/compression.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Brotli compression wrapper + * Handles compression and decompression with flag byte prefix + */ + +const COMPRESSION_FLAG_NONE = 0x00; +const COMPRESSION_FLAG_BROTLI = 0x01; + +type BrotliModule = { + compress: (data: Uint8Array, options?: { quality?: number; lgwin?: number }) => Uint8Array; + decompress: (data: Uint8Array) => Uint8Array; +}; + +let brotliModule: BrotliModule | null = null; + +/** + * Initialize brotli module (idempotent) + * Uses Node.js zlib in Node environment, brotli-wasm in browser + */ +async function ensureBrotliInitialized(): Promise { + if (!brotliModule) { + // Detect environment + if (typeof process !== 'undefined' && process.versions?.node) { + // Node.js environment - use zlib + try { + const zlib = await import('node:zlib'); + const { promisify } = await import('node:util'); + const brotliCompressAsync = promisify(zlib.brotliCompress); + const brotliDecompressAsync = promisify(zlib.brotliDecompress); + + brotliModule = { + compress: (data, options) => { + // Synchronous version for Node.js (simpler for this use case) + const params = { + [zlib.constants.BROTLI_PARAM_QUALITY]: options?.quality || 5, + [zlib.constants.BROTLI_PARAM_LGWIN]: options?.lgwin || 22, + }; + return new Uint8Array(zlib.brotliCompressSync(data, { params })); + }, + decompress: (data) => { + return new Uint8Array(zlib.brotliDecompressSync(data)); + }, + }; + } catch (error) { + throw new Error( + `Failed to initialize Node.js brotli: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + } else { + // Browser environment - use brotli-wasm + try { + const brotliPromise = await import('brotli-wasm'); + brotliModule = (await brotliPromise.default) as BrotliModule; + } catch (error) { + throw new Error( + `Failed to initialize brotli-wasm: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + } + } +} + +/** + * Compress payload with Brotli if beneficial + * @param data - Data to compress + * @returns Object with compressed data and flag byte + */ +export async function compressPayload( + data: Uint8Array +): Promise<{ compressed: Uint8Array; flag: 0x00 | 0x01 }> { + await ensureBrotliInitialized(); + + if (!brotliModule) { + throw new Error('Brotli module not initialized'); + } + + // Try Brotli compression (quality 5, window 22 bits per spec) + const compressed = brotliModule.compress(data, { + quality: 5, + lgwin: 22, // 22 bits = 4 MB window + }); + + // Only use compression if it's actually smaller (including the flag byte) + if (compressed.length + 1 < data.length + 1) { + return { + compressed, + flag: COMPRESSION_FLAG_BROTLI, + }; + } + + // No compression is better + return { + compressed: data, + flag: COMPRESSION_FLAG_NONE, + }; +} + +/** + * Decompress payload based on flag byte + * @param data - Data with flag byte prefix + * @returns Decompressed data + * @throws Error if compression flag is unknown or decompression fails + */ +export async function decompressPayload(data: Uint8Array): Promise { + if (data.length === 0) { + throw new Error('Cannot decompress empty payload'); + } + + const flag = data[0]; + const payload = data.slice(1); + + if (flag === COMPRESSION_FLAG_NONE) { + return payload; + } + + if (flag === COMPRESSION_FLAG_BROTLI) { + await ensureBrotliInitialized(); + + if (!brotliModule) { + throw new Error('Brotli module not initialized'); + } + + try { + const decompressed = brotliModule.decompress(payload); + return decompressed; + } catch (error) { + throw new Error( + `Brotli decompression failed: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + } + + throw new Error(`Unknown compression flag: 0x${flag.toString(16).padStart(2, '0')}`); +} + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.test.ts b/packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.test.ts new file mode 100644 index 00000000..c9a6f393 --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +import { describe, expect, it } from 'vitest'; +import { + bytesToHex, + decodeAddress, + decodeAmount, + decodeCapabilities, + encodeAddress, + encodeAmount, + encodeCapabilities, + hexToBytes, + pad32, +} from './encoding.js'; + +describe('encoding', () => { + describe('encodeAddress / decodeAddress', () => { + it('should encode and decode address', () => { + const address = '0x1234567890123456789012345678901234567890'; + const encoded = encodeAddress(address); + expect(encoded.length).toBe(20); + const decoded = decodeAddress(encoded); + expect(decoded).toBe(address); + }); + + it('should normalize address to lowercase', () => { + const address = '0xABCDEF1234567890123456789012345678901234'; + const encoded = encodeAddress(address); + const decoded = decodeAddress(encoded); + expect(decoded).toBe(address.toLowerCase()); + }); + + it('should handle address without 0x prefix', () => { + const address = '1234567890123456789012345678901234567890'; + const encoded = encodeAddress(address); + expect(encoded.length).toBe(20); + }); + + it('should throw on invalid address length', () => { + expect(() => encodeAddress('0x1234')).toThrow(/Invalid address length/); + expect(() => decodeAddress(new Uint8Array(10))).toThrow(/Invalid address length/); + }); + }); + + describe('encodeAmount / decodeAmount', () => { + it('should encode zero', () => { + const encoded = encodeAmount(0n); + expect(encoded).toEqual(new Uint8Array([0x00])); + expect(decodeAmount(encoded)).toBe(0n); + }); + + it('should encode zero from string', () => { + const encoded = encodeAmount('0x0'); + expect(encoded).toEqual(new Uint8Array([0x00])); + }); + + it('should decode empty bytes as zero', () => { + const decoded = decodeAmount(new Uint8Array([])); + expect(decoded).toBe(0n); + }); + + it('should encode small amounts minimally', () => { + const encoded = encodeAmount(255n); + expect(encoded).toEqual(new Uint8Array([0xff])); + expect(decodeAmount(encoded)).toBe(255n); + }); + + it('should encode large amounts minimally', () => { + const encoded = encodeAmount(5000000n); // 0x4c4b40 + expect(encoded).toEqual(new Uint8Array([0x4c, 0x4b, 0x40])); + expect(decodeAmount(encoded)).toBe(5000000n); + }); + + it('should encode from hex string', () => { + const encoded = encodeAmount('0x4c4b40'); + expect(encoded).toEqual(new Uint8Array([0x4c, 0x4b, 0x40])); + expect(decodeAmount(encoded)).toBe(5000000n); + }); + + it('should reject leading zeros in decoding', () => { + const invalidEncoding = new Uint8Array([0x00, 0x01]); + expect(() => decodeAmount(invalidEncoding)).toThrow(/leading zeros/); + }); + + it('should reject negative amounts', () => { + expect(() => encodeAmount(-1n)).toThrow(/negative/); + }); + + it('should handle 1 ETH (18 decimals)', () => { + const oneEth = 10n ** 18n; // 0xde0b6b3a7640000 + const encoded = encodeAmount(oneEth); + expect(encoded).toEqual(new Uint8Array([0x0d, 0xe0, 0xb6, 0xb3, 0xa7, 0x64, 0x00, 0x00])); + expect(decodeAmount(encoded)).toBe(oneEth); + }); + }); + + describe('encodeCapabilities / decodeCapabilities', () => { + it('should encode empty capabilities', () => { + const encoded = encodeCapabilities({}); + expect(encoded.size).toBe(0); + }); + + it('should encode string capability', () => { + const caps = { orderId: 'ORDER-123' }; + const encoded = encodeCapabilities(caps); + expect(encoded.size).toBe(1); + const decoded = decodeCapabilities(encoded); + expect(decoded).toEqual(caps); + }); + + it('should encode object capability', () => { + const caps = { + dataCallback: { + callbackURL: 'https://example.com', + events: ['initiated'], + }, + }; + const encoded = encodeCapabilities(caps); + const decoded = decodeCapabilities(encoded); + expect(decoded).toEqual(caps); + }); + + it('should encode multiple capabilities', () => { + const caps = { + order_id: 'ORDER-123', + tip_bps: 50, + dataCallback: { callbackURL: 'https://example.com' }, + }; + const encoded = encodeCapabilities(caps); + const decoded = decodeCapabilities(encoded); + expect(decoded).toEqual(caps); + }); + + it('should throw on invalid JSON in decode', () => { + const invalidMap = new Map(); + invalidMap.set('test', new Uint8Array([0xff, 0xfe])); // Invalid UTF-8 + expect(() => decodeCapabilities(invalidMap)).toThrow(); + }); + }); + + describe('pad32', () => { + it('should pad address to 32 bytes', () => { + const address = new Uint8Array(20).fill(0x42); + const padded = pad32(address); + expect(padded.length).toBe(32); + expect(padded.slice(0, 12)).toEqual(new Uint8Array(12).fill(0)); + expect(padded.slice(12)).toEqual(address); + }); + + it('should pad amount to 32 bytes', () => { + const amount = new Uint8Array([0x01, 0x02, 0x03]); + const padded = pad32(amount); + expect(padded.length).toBe(32); + expect(padded[29]).toBe(0x01); + expect(padded[30]).toBe(0x02); + expect(padded[31]).toBe(0x03); + }); + + it('should not modify 32-byte input', () => { + const data = new Uint8Array(32).fill(0x42); + const padded = pad32(data); + expect(padded).toEqual(data); + }); + + it('should throw on input larger than 32 bytes', () => { + const tooLarge = new Uint8Array(33); + expect(() => pad32(tooLarge)).toThrow(/larger than 32 bytes/); + }); + }); + + describe('bytesToHex / hexToBytes', () => { + it('should convert bytes to hex', () => { + const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78]); + const hex = bytesToHex(bytes); + expect(hex).toBe('0x12345678'); + }); + + it('should convert zero to 0x0', () => { + const bytes = new Uint8Array([0x00]); + const hex = bytesToHex(bytes); + expect(hex).toBe('0x0'); + }); + + it('should remove leading zeros (minimal encoding)', () => { + const bytes = new Uint8Array([0x00, 0x00, 0x12, 0x34]); + const hex = bytesToHex(bytes); + expect(hex).toBe('0x1234'); + }); + + it('should handle single leading zero byte', () => { + const bytes = new Uint8Array([0x00, 0x12]); + const hex = bytesToHex(bytes); + expect(hex).toBe('0x12'); + }); + + it('should convert hex to bytes', () => { + const hex = '0x12345678'; + const bytes = hexToBytes(hex); + expect(bytes).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78])); + }); + + it('should handle hex without 0x prefix', () => { + const bytes = hexToBytes('12345678'); + expect(bytes).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78])); + }); + + it('should roundtrip correctly with leading zeros handled', () => { + const original = new Uint8Array([1, 2, 255, 254, 253]); + const hex = bytesToHex(original); + const result = hexToBytes(hex); + // bytesToHex strips leading zeros, so we need to compare values + expect(BigInt(hex)).toBe(BigInt('0x' + Array.from(original).map(b => b.toString(16).padStart(2, '0')).join(''))); + }); + }); +}); + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.ts b/packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.ts new file mode 100644 index 00000000..85dc7f6d --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/utils/encoding.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Field encoding helpers for canonical encoding + */ + +/** + * Encode an Ethereum address to 20 bytes + * @param address - Hex address string (with or without 0x prefix) + * @returns 20-byte address + * @throws Error if address is not 20 bytes + */ +export function encodeAddress(address: string): Uint8Array { + // Remove 0x prefix if present and normalize to lowercase + const normalized = address.toLowerCase().replace(/^0x/, ''); + + if (normalized.length !== 40) { + throw new Error(`Invalid address length: expected 40 hex chars, got ${normalized.length}`); + } + + const bytes = new Uint8Array(20); + for (let i = 0; i < 20; i++) { + bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16); + } + + return bytes; +} + +/** + * Decode 20-byte address to hex string + * @param bytes - 20-byte address + * @returns Hex address string with 0x prefix + */ +export function decodeAddress(bytes: Uint8Array): string { + if (bytes.length !== 20) { + throw new Error(`Invalid address length: expected 20 bytes, got ${bytes.length}`); + } + + let hex = '0x'; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return hex; +} + +/** + * Encode an amount to minimal big-endian bytes (no leading zeros) + * @param value - Amount as bigint or hex string + * @returns Minimal big-endian bytes + */ +export function encodeAmount(value: bigint | string): Uint8Array { + let bigintValue: bigint; + + if (typeof value === 'string') { + // Handle hex strings + const normalized = value.toLowerCase().replace(/^0x/, ''); + if (normalized === '' || normalized === '0') { + return new Uint8Array([0x00]); + } + bigintValue = BigInt(`0x${normalized}`); + } else { + bigintValue = value; + } + + // Handle zero + if (bigintValue === 0n) { + return new Uint8Array([0x00]); + } + + // Handle negative (not allowed) + if (bigintValue < 0n) { + throw new Error('Cannot encode negative amounts'); + } + + // Convert to minimal big-endian bytes + const hex = bigintValue.toString(16); + const bytes = new Uint8Array(Math.ceil(hex.length / 2)); + + for (let i = 0; i < bytes.length; i++) { + const offset = hex.length - (bytes.length - i) * 2; + const byteHex = offset < 0 ? hex[0] : hex.slice(offset, offset + 2); + bytes[i] = Number.parseInt(byteHex, 16); + } + + return bytes; +} + +/** + * Decode minimal big-endian bytes to bigint + * @param bytes - Minimal big-endian bytes (or empty for zero) + * @returns Amount as bigint + */ +export function decodeAmount(bytes: Uint8Array): bigint { + if (bytes.length === 0) { + return 0n; + } + + // Validate no leading zeros (except for single 0x00) + if (bytes.length > 1 && bytes[0] === 0) { + throw new Error('Invalid amount encoding: leading zeros not allowed'); + } + + let hex = ''; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + + return BigInt(`0x${hex}`); +} + +/** + * Encode capabilities map to protobuf format + * @param caps - Capabilities object + * @returns Map with UTF-8 JSON-encoded values + */ +export function encodeCapabilities(caps: Record): Map { + const map = new Map(); + + for (const [key, value] of Object.entries(caps)) { + const json = JSON.stringify(value); + const bytes = new TextEncoder().encode(json); + map.set(key, bytes); + } + + return map; +} + +/** + * Decode capabilities map from protobuf format + * @param map - Map with UTF-8 JSON-encoded values + * @returns Capabilities object + */ +export function decodeCapabilities(map: Map): Record { + const caps: Record = {}; + + for (const [key, bytes] of map.entries()) { + try { + const json = new TextDecoder().decode(bytes); + caps[key] = JSON.parse(json); + } catch (error) { + throw new Error( + `Failed to decode capability '${key}': ${error instanceof Error ? error.message : 'unknown error'}` + ); + } + } + + return caps; +} + +/** + * Pad a value to 32 bytes (for EIP-712 encoding) + * @param bytes - Value to pad + * @returns 32-byte padded value + */ +export function pad32(bytes: Uint8Array): Uint8Array { + if (bytes.length > 32) { + throw new Error(`Cannot pad value larger than 32 bytes: ${bytes.length}`); + } + + const padded = new Uint8Array(32); + // Left-pad with zeros + padded.set(bytes, 32 - bytes.length); + return padded; +} + +/** + * Convert bytes to hex string with 0x prefix + * Minimal encoding: no leading zeros unless value is zero + * @param bytes - Bytes to convert + * @returns Hex string + */ +export function bytesToHex(bytes: Uint8Array): string { + if (bytes.length === 0) { + return '0x0'; + } + + // For single byte 0, return 0x0 + if (bytes.length === 1 && bytes[0] === 0) { + return '0x0'; + } + + let hex = '0x'; + let foundNonZero = false; + + for (let i = 0; i < bytes.length; i++) { + // Skip leading zero bytes (but keep the last byte even if it's zero) + if (!foundNonZero && bytes[i] === 0 && i < bytes.length - 1) { + continue; + } + foundNonZero = true; + + // For the first non-zero byte, don't pad if it's a single digit + if (foundNonZero && hex === '0x' && bytes[i] < 16) { + hex += bytes[i].toString(16); + } else { + hex += bytes[i].toString(16).padStart(2, '0'); + } + } + + return hex; +} + +/** + * Convert hex string to bytes + * @param hex - Hex string (with or without 0x prefix) + * @returns Bytes + */ +export function hexToBytes(hex: string): Uint8Array { + const normalized = hex.toLowerCase().replace(/^0x/, ''); + const bytes = new Uint8Array(normalized.length / 2); + + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16); + } + + return bytes; +} + diff --git a/packages/account-sdk/src/interface/public-utilities/prolink/utils/protobuf.ts b/packages/account-sdk/src/interface/public-utilities/prolink/utils/protobuf.ts new file mode 100644 index 00000000..e0156f5a --- /dev/null +++ b/packages/account-sdk/src/interface/public-utilities/prolink/utils/protobuf.ts @@ -0,0 +1,630 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +/** + * Protocol Buffers wire format encoding/decoding + * Implements a subset of proto3 wire format for our message types + */ + +import type { + Call, + Erc20Transfer, + GenericCalls, + GenericJsonRpc, + GenericTypedData, + NativeTransfer, + ReceiveWithAuthorization, + RpcLinkPayload, + SpendPermission, + WalletSendCalls, + WalletSign, +} from '../types.js'; +import { SendCallsType, SignType } from '../types.js'; + +// Wire types +const WIRE_TYPE_VARINT = 0; +const WIRE_TYPE_LENGTH_DELIMITED = 2; + +/** + * Encode a varint (unsigned) + */ +function encodeVarint(value: number | bigint): Uint8Array { + const result: number[] = []; + let n = typeof value === 'bigint' ? value : BigInt(value); + + if (n < 0n) { + throw new Error('Cannot encode negative varint'); + } + + do { + let byte = Number(n & 0x7fn); + n >>= 7n; + if (n !== 0n) { + byte |= 0x80; + } + result.push(byte); + } while (n !== 0n); + + return new Uint8Array(result); +} + +/** + * Decode a varint from buffer + */ +function decodeVarint(buffer: Uint8Array, offset: number): { value: bigint; length: number } { + let value = 0n; + let shift = 0n; + let length = 0; + + while (offset + length < buffer.length) { + const byte = buffer[offset + length]; + length++; + + value |= BigInt(byte & 0x7f) << shift; + shift += 7n; + + if ((byte & 0x80) === 0) { + return { value, length }; + } + } + + throw new Error('Incomplete varint'); +} + +/** + * Encode a length-delimited field + */ +function encodeBytes(fieldNumber: number, value: Uint8Array): Uint8Array { + if (value.length === 0) return new Uint8Array(0); // Omit empty bytes + + const tag = (fieldNumber << 3) | WIRE_TYPE_LENGTH_DELIMITED; + const tagBytes = encodeVarint(tag); + const lengthBytes = encodeVarint(value.length); + + const result = new Uint8Array(tagBytes.length + lengthBytes.length + value.length); + result.set(tagBytes, 0); + result.set(lengthBytes, tagBytes.length); + result.set(value, tagBytes.length + lengthBytes.length); + + return result; +} + +/** + * Encode a varint field + */ +function encodeVarintField(fieldNumber: number, value: number | bigint): Uint8Array { + if (value === 0 || value === 0n) return new Uint8Array(0); // Omit zero values in proto3 + + const tag = (fieldNumber << 3) | WIRE_TYPE_VARINT; + const tagBytes = encodeVarint(tag); + const valueBytes = encodeVarint(value); + + const result = new Uint8Array(tagBytes.length + valueBytes.length); + result.set(tagBytes, 0); + result.set(valueBytes, tagBytes.length); + + return result; +} + +/** + * Encode a string field + */ +function encodeString(fieldNumber: number, value: string): Uint8Array { + if (!value) return new Uint8Array(0); // Omit empty strings + + const bytes = new TextEncoder().encode(value); + return encodeBytes(fieldNumber, bytes); +} + +/** + * Encode a message field + */ +function encodeMessage(fieldNumber: number, value: Uint8Array): Uint8Array { + if (value.length === 0) return new Uint8Array(0); // Omit empty messages + + return encodeBytes(fieldNumber, value); +} + +/** + * Concatenate byte arrays + */ +function concat(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +/** + * Encode Erc20Transfer message + */ +function encodeErc20Transfer(value: Erc20Transfer): Uint8Array { + return concat( + encodeBytes(1, value.token), + encodeBytes(2, value.recipient), + encodeBytes(3, value.amount) + ); +} + +/** + * Encode NativeTransfer message + */ +function encodeNativeTransfer(value: NativeTransfer): Uint8Array { + return concat(encodeBytes(1, value.recipient), encodeBytes(2, value.amount)); +} + +/** + * Encode Call message + */ +function encodeCall(value: Call): Uint8Array { + return concat(encodeBytes(1, value.to), encodeBytes(2, value.data), encodeBytes(3, value.value)); +} + +/** + * Encode GenericCalls message + */ +function encodeGenericCalls(value: GenericCalls): Uint8Array { + const parts: Uint8Array[] = []; + for (const call of value.calls) { + const callBytes = encodeCall(call); + parts.push(encodeMessage(1, callBytes)); + } + return concat(...parts); +} + +/** + * Encode WalletSendCalls message + */ +export function encodeWalletSendCalls(value: WalletSendCalls): Uint8Array { + const parts: Uint8Array[] = [encodeVarintField(1, value.type)]; + + // Encode transaction data based on type + if (value.transactionData.case === 'erc20Transfer') { + const encoded = encodeErc20Transfer(value.transactionData.value); + parts.push(encodeMessage(10, encoded)); + } else if (value.transactionData.case === 'nativeTransfer') { + const encoded = encodeNativeTransfer(value.transactionData.value); + parts.push(encodeMessage(11, encoded)); + } else if (value.transactionData.case === 'genericCalls') { + const encoded = encodeGenericCalls(value.transactionData.value); + parts.push(encodeMessage(12, encoded)); + } + + if (value.from) { + parts.push(encodeBytes(3, value.from)); + } + + if (value.version) { + parts.push(encodeString(4, value.version)); + } + + return concat(...parts); +} + +/** + * Encode SpendPermission message + */ +function encodeSpendPermission(value: SpendPermission): Uint8Array { + return concat( + encodeBytes(1, value.account), + encodeBytes(2, value.spender), + encodeBytes(3, value.token), + encodeBytes(4, value.allowance), + encodeVarintField(5, value.period), + encodeVarintField(6, value.start), + encodeVarintField(7, value.end), + encodeBytes(8, value.salt), + encodeBytes(9, value.extraData), + encodeBytes(10, value.verifyingContract), + encodeString(11, value.domainName), + encodeString(12, value.domainVersion) + ); +} + +/** + * Encode ReceiveWithAuthorization message + */ +function encodeReceiveWithAuthorization(value: ReceiveWithAuthorization): Uint8Array { + return concat( + encodeBytes(1, value.from), + encodeBytes(2, value.to), + encodeBytes(3, value.value), + encodeBytes(4, value.validAfter), + encodeBytes(5, value.validBefore), + encodeBytes(6, value.nonce), + encodeBytes(7, value.verifyingContract), + encodeString(8, value.domainName), + encodeString(9, value.domainVersion) + ); +} + +/** + * Encode GenericTypedData message + */ +function encodeGenericTypedData(value: GenericTypedData): Uint8Array { + return encodeBytes(1, value.typedDataJson); +} + +/** + * Encode WalletSign message + */ +export function encodeWalletSign(value: WalletSign): Uint8Array { + const parts: Uint8Array[] = [encodeVarintField(1, value.type)]; + + if (value.signatureData.case === 'spendPermission') { + const encoded = encodeSpendPermission(value.signatureData.value); + parts.push(encodeMessage(10, encoded)); + } else if (value.signatureData.case === 'receiveWithAuthorization') { + const encoded = encodeReceiveWithAuthorization(value.signatureData.value); + parts.push(encodeMessage(11, encoded)); + } else if (value.signatureData.case === 'genericTypedData') { + const encoded = encodeGenericTypedData(value.signatureData.value); + parts.push(encodeMessage(12, encoded)); + } + + if (value.version) { + parts.push(encodeString(3, value.version)); + } + + return concat(...parts); +} + +/** + * Encode GenericJsonRpc message + */ +export function encodeGenericJsonRpc(value: GenericJsonRpc): Uint8Array { + return concat( + encodeString(1, value.method), + encodeBytes(2, value.paramsJson), + encodeString(3, value.rpcVersion || '') + ); +} + +/** + * Encode map field (for capabilities) + */ +function encodeMap(fieldNumber: number, map: Map): Uint8Array { + const parts: Uint8Array[] = []; + + for (const [key, value] of map.entries()) { + // Each map entry is encoded as a message with field 1 = key, field 2 = value + const entryBytes = concat(encodeString(1, key), encodeBytes(2, value)); + parts.push(encodeMessage(fieldNumber, entryBytes)); + } + + return concat(...parts); +} + +/** + * Encode RpcLinkPayload message + */ +export function encodeRpcLinkPayload(value: RpcLinkPayload): Uint8Array { + const parts: Uint8Array[] = [ + encodeVarintField(1, value.protocolVersion), + encodeVarintField(2, value.chainId || 0), + encodeVarintField(3, value.shortcutId), + encodeVarintField(4, value.shortcutVersion), + ]; + + // Encode body based on shortcut + if (value.body.case === 'generic') { + const encoded = encodeGenericJsonRpc(value.body.value); + parts.push(encodeMessage(10, encoded)); + } else if (value.body.case === 'walletSendCalls') { + const encoded = encodeWalletSendCalls(value.body.value); + parts.push(encodeMessage(11, encoded)); + } else if (value.body.case === 'walletSign') { + const encoded = encodeWalletSign(value.body.value); + parts.push(encodeMessage(12, encoded)); + } + + // Encode capabilities map + if (value.capabilities && value.capabilities.size > 0) { + parts.push(encodeMap(20, value.capabilities)); + } + + return concat(...parts); +} + +/** + * Decode a protobuf message + * This is a simplified decoder that reads fields sequentially + */ +export function decodeRpcLinkPayload(buffer: Uint8Array): RpcLinkPayload { + const fields = parseFields(buffer); + + const protocolVersion = Number(fields.get(1) || 0n); + const chainId = fields.get(2) ? Number(fields.get(2)) : undefined; + const shortcutId = Number(fields.get(3) || 0n); + const shortcutVersion = Number(fields.get(4) || 0n); + + let body: RpcLinkPayload['body'] = { case: undefined }; + + // Decode body based on which field is present + if (fields.has(10)) { + const genericBytes = fields.get(10) as Uint8Array; + body = { case: 'generic', value: decodeGenericJsonRpc(genericBytes) }; + } else if (fields.has(11)) { + const sendCallsBytes = fields.get(11) as Uint8Array; + body = { case: 'walletSendCalls', value: decodeWalletSendCalls(sendCallsBytes) }; + } else if (fields.has(12)) { + const signBytes = fields.get(12) as Uint8Array; + body = { case: 'walletSign', value: decodeWalletSign(signBytes) }; + } + + // Decode capabilities map + const capabilities = fields.get(20) ? (fields.get(20) as Map) : undefined; + + return { + protocolVersion, + chainId, + shortcutId, + shortcutVersion, + body, + capabilities, + }; +} + +/** + * Parse protobuf fields from buffer + */ +function parseFields(buffer: Uint8Array): Map> { + const fields = new Map>(); + let offset = 0; + + while (offset < buffer.length) { + const { value: tag, length: tagLength } = decodeVarint(buffer, offset); + offset += tagLength; + + const fieldNumber = Number(tag >> 3n); + const wireType = Number(tag & 0x7n); + + if (wireType === WIRE_TYPE_VARINT) { + const { value, length } = decodeVarint(buffer, offset); + offset += length; + fields.set(fieldNumber, value); + } else if (wireType === WIRE_TYPE_LENGTH_DELIMITED) { + const { value: length, length: lengthSize } = decodeVarint(buffer, offset); + offset += lengthSize; + + const bytes = buffer.slice(offset, offset + Number(length)); + offset += Number(length); + + // Field 20 is the capabilities map + if (fieldNumber === 20) { + if (!fields.has(20)) { + fields.set(20, new Map()); + } + const mapField = fields.get(20) as Map; + const entry = parseMapEntry(bytes); + mapField.set(entry.key, entry.value); + } else { + fields.set(fieldNumber, bytes); + } + } else { + throw new Error(`Unsupported wire type: ${wireType}`); + } + } + + return fields; +} + +/** + * Parse a map entry (key-value pair) + */ +function parseMapEntry(buffer: Uint8Array): { key: string; value: Uint8Array } { + const fields = parseFields(buffer); + const keyBytes = fields.get(1) as Uint8Array; + const valueBytes = fields.get(2) as Uint8Array; + + if (!keyBytes || !valueBytes) { + throw new Error('Invalid map entry: missing key or value'); + } + + const key = new TextDecoder().decode(keyBytes); + return { key, value: valueBytes }; +} + +/** + * Decode GenericJsonRpc message + */ +function decodeGenericJsonRpc(buffer: Uint8Array): GenericJsonRpc { + const fields = parseFields(buffer); + + const methodBytes = fields.get(1) as Uint8Array; + const paramsJsonBytes = fields.get(2) as Uint8Array; + const rpcVersionBytes = fields.get(3) as Uint8Array | undefined; + + return { + method: new TextDecoder().decode(methodBytes || new Uint8Array()), + paramsJson: paramsJsonBytes || new Uint8Array(), + rpcVersion: rpcVersionBytes ? new TextDecoder().decode(rpcVersionBytes) : undefined, + }; +} + +/** + * Decode WalletSendCalls message + */ +function decodeWalletSendCalls(buffer: Uint8Array): WalletSendCalls { + const fields = parseFields(buffer); + + const type = Number(fields.get(1) || 0n) as SendCallsType; + const fromBytes = fields.get(3) as Uint8Array | undefined; + const versionBytes = fields.get(4) as Uint8Array | undefined; + + let transactionData: WalletSendCalls['transactionData'] = { case: undefined }; + + if (fields.has(10)) { + const erc20Bytes = fields.get(10) as Uint8Array; + transactionData = { case: 'erc20Transfer', value: decodeErc20Transfer(erc20Bytes) }; + } else if (fields.has(11)) { + const nativeBytes = fields.get(11) as Uint8Array; + transactionData = { case: 'nativeTransfer', value: decodeNativeTransfer(nativeBytes) }; + } else if (fields.has(12)) { + const genericBytes = fields.get(12) as Uint8Array; + transactionData = { case: 'genericCalls', value: decodeGenericCalls(genericBytes) }; + } + + return { + type, + transactionData, + from: fromBytes, + version: versionBytes ? new TextDecoder().decode(versionBytes) : undefined, + }; +} + +/** + * Decode Erc20Transfer message + */ +function decodeErc20Transfer(buffer: Uint8Array): Erc20Transfer { + const fields = parseFields(buffer); + + return { + token: (fields.get(1) as Uint8Array) || new Uint8Array(), + recipient: (fields.get(2) as Uint8Array) || new Uint8Array(), + amount: (fields.get(3) as Uint8Array) || new Uint8Array(), + }; +} + +/** + * Decode NativeTransfer message + */ +function decodeNativeTransfer(buffer: Uint8Array): NativeTransfer { + const fields = parseFields(buffer); + + return { + recipient: (fields.get(1) as Uint8Array) || new Uint8Array(), + amount: (fields.get(2) as Uint8Array) || new Uint8Array(), + }; +} + +/** + * Decode Call message + */ +function decodeCall(buffer: Uint8Array): Call { + const fields = parseFields(buffer); + + return { + to: (fields.get(1) as Uint8Array) || new Uint8Array(), + data: (fields.get(2) as Uint8Array) || new Uint8Array(), + value: (fields.get(3) as Uint8Array) || new Uint8Array(), + }; +} + +/** + * Decode GenericCalls message + */ +function decodeGenericCalls(buffer: Uint8Array): GenericCalls { + const calls: Call[] = []; + let offset = 0; + + while (offset < buffer.length) { + const { value: tag, length: tagLength } = decodeVarint(buffer, offset); + offset += tagLength; + + const fieldNumber = Number(tag >> 3n); + const wireType = Number(tag & 0x7n); + + if (fieldNumber === 1 && wireType === WIRE_TYPE_LENGTH_DELIMITED) { + const { value: length, length: lengthSize } = decodeVarint(buffer, offset); + offset += lengthSize; + + const callBytes = buffer.slice(offset, offset + Number(length)); + offset += Number(length); + + calls.push(decodeCall(callBytes)); + } else { + throw new Error(`Unexpected field in GenericCalls: ${fieldNumber}`); + } + } + + return { calls }; +} + +/** + * Decode WalletSign message + */ +function decodeWalletSign(buffer: Uint8Array): WalletSign { + const fields = parseFields(buffer); + + const type = Number(fields.get(1) || 0n) as SignType; + const versionBytes = fields.get(3) as Uint8Array | undefined; + + let signatureData: WalletSign['signatureData'] = { case: undefined }; + + if (fields.has(10)) { + const spendBytes = fields.get(10) as Uint8Array; + signatureData = { case: 'spendPermission', value: decodeSpendPermission(spendBytes) }; + } else if (fields.has(11)) { + const receiveBytes = fields.get(11) as Uint8Array; + signatureData = { + case: 'receiveWithAuthorization', + value: decodeReceiveWithAuthorization(receiveBytes), + }; + } else if (fields.has(12)) { + const genericBytes = fields.get(12) as Uint8Array; + signatureData = { case: 'genericTypedData', value: decodeGenericTypedData(genericBytes) }; + } + + return { + type, + signatureData, + version: versionBytes ? new TextDecoder().decode(versionBytes) : undefined, + }; +} + +/** + * Decode SpendPermission message + */ +function decodeSpendPermission(buffer: Uint8Array): SpendPermission { + const fields = parseFields(buffer); + + return { + account: (fields.get(1) as Uint8Array) || new Uint8Array(), + spender: (fields.get(2) as Uint8Array) || new Uint8Array(), + token: (fields.get(3) as Uint8Array) || new Uint8Array(), + allowance: (fields.get(4) as Uint8Array) || new Uint8Array(), + period: (fields.get(5) as bigint) || 0n, + start: (fields.get(6) as bigint) || 0n, + end: (fields.get(7) as bigint) || 0n, + salt: (fields.get(8) as Uint8Array) || new Uint8Array(), + extraData: (fields.get(9) as Uint8Array) || new Uint8Array(), + verifyingContract: (fields.get(10) as Uint8Array) || new Uint8Array(), + domainName: new TextDecoder().decode((fields.get(11) as Uint8Array) || new Uint8Array()), + domainVersion: new TextDecoder().decode((fields.get(12) as Uint8Array) || new Uint8Array()), + }; +} + +/** + * Decode ReceiveWithAuthorization message + */ +function decodeReceiveWithAuthorization(buffer: Uint8Array): ReceiveWithAuthorization { + const fields = parseFields(buffer); + + return { + from: (fields.get(1) as Uint8Array) || new Uint8Array(), + to: (fields.get(2) as Uint8Array) || new Uint8Array(), + value: (fields.get(3) as Uint8Array) || new Uint8Array(), + validAfter: (fields.get(4) as Uint8Array) || new Uint8Array(), + validBefore: (fields.get(5) as Uint8Array) || new Uint8Array(), + nonce: (fields.get(6) as Uint8Array) || new Uint8Array(), + verifyingContract: (fields.get(7) as Uint8Array) || new Uint8Array(), + domainName: new TextDecoder().decode((fields.get(8) as Uint8Array) || new Uint8Array()), + domainVersion: new TextDecoder().decode((fields.get(9) as Uint8Array) || new Uint8Array()), + }; +} + +/** + * Decode GenericTypedData message + */ +function decodeGenericTypedData(buffer: Uint8Array): GenericTypedData { + const fields = parseFields(buffer); + + return { + typedDataJson: (fields.get(1) as Uint8Array) || new Uint8Array(), + }; +} + diff --git a/packages/account-sdk/src/ui/ProlinkDialog/ProlinkDialog.tsx b/packages/account-sdk/src/ui/ProlinkDialog/ProlinkDialog.tsx new file mode 100644 index 00000000..e5620bfa --- /dev/null +++ b/packages/account-sdk/src/ui/ProlinkDialog/ProlinkDialog.tsx @@ -0,0 +1,234 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +import { FunctionComponent } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import QRCode from 'qrcode'; + +import { Dialog, type DialogProps } from ':ui/Dialog/Dialog.js'; + +export type ProlinkDialogProps = { + payload: string; + title?: string; + message?: string; + onClose?: () => void; +}; + +/** + * Dialog component for displaying a prolink QR code + */ +export const ProlinkDialog: FunctionComponent = ({ + payload, + title = 'Scan to Connect', + message = 'Scan this QR code with your wallet app to complete the transaction.', + onClose, +}) => { + const [qrDataUrl, setQrDataUrl] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + const generateQR = async () => { + try { + // Generate QR code as data URL + const dataUrl = await QRCode.toDataURL(payload, { + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + errorCorrectionLevel: 'M', + }); + setQrDataUrl(dataUrl); + } catch (err) { + console.error('Failed to generate QR code:', err); + setError('Failed to generate QR code'); + } + }; + + generateQR(); + }, [payload]); + + return ( + + ); +}; + +const styles = ` +.-base-acc-sdk-prolink-dialog { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; +} + +.-base-acc-sdk-prolink-dialog-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.-base-acc-sdk-prolink-dialog-title { + font-size: 20px; + font-weight: 600; + color: #000; + text-align: center; +} + +.-base-acc-sdk-prolink-dialog-message { + font-size: 14px; + color: #666; + text-align: center; + max-width: 400px; +} + +.-base-acc-sdk-prolink-dialog-qr-container { + padding: 16px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.-base-acc-sdk-prolink-dialog-qr-image { + display: block; + width: 300px; + height: 300px; +} + +.-base-acc-sdk-prolink-dialog-loading { + padding: 40px; + font-size: 14px; + color: #666; +} + +.-base-acc-sdk-prolink-dialog-error { + padding: 16px; + background: #fee; + border: 1px solid #fcc; + border-radius: 4px; + color: #c00; + font-size: 14px; +} + +.-base-acc-sdk-prolink-dialog-payload-preview { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.-base-acc-sdk-prolink-dialog-payload-label { + font-size: 12px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.-base-acc-sdk-prolink-dialog-payload-text { + font-family: monospace; + font-size: 12px; + color: #666; + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + word-break: break-all; +} + +.-base-acc-sdk-prolink-dialog-actions { + display: flex; + justify-content: center; + margin-top: 8px; +} + +.-base-acc-sdk-prolink-dialog-button { + padding: 12px 24px; + background: #0052ff; + color: #fff; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.-base-acc-sdk-prolink-dialog-button:hover { + background: #0040cc; +} + +/* Mobile optimizations */ +@media (max-width: 600px) { + .-base-acc-sdk-prolink-dialog { + padding: 16px; + } + + .-base-acc-sdk-prolink-dialog-qr-image { + width: 250px; + height: 250px; + } +} +`; + +let dialogInstance: Dialog | null = null; + +/** + * Show a prolink dialog with QR code + * @param payload - Base64url-encoded prolink payload + * @param options - Optional title and message + */ +export function showProlinkDialog( + payload: string, + options?: { title?: string; message?: string } +): void { + if (!dialogInstance) { + dialogInstance = new Dialog(); + dialogInstance.attach(document.body); + } + + const dialogProps: DialogProps = { + title: options?.title || 'Scan to Connect', + message: options?.message || 'Scan this QR code with your wallet app.', + onClose: () => { + dialogInstance?.clear(); + }, + }; + + // We need to integrate the QR code into the existing Dialog system + // For now, we'll use the message field to render the QR code + // In a production implementation, you'd extend the Dialog component + // to support custom content + + dialogInstance.presentItem(dialogProps); +} + diff --git a/packages/account-sdk/src/ui/ProlinkDialog/index.ts b/packages/account-sdk/src/ui/ProlinkDialog/index.ts new file mode 100644 index 00000000..27e44a5e --- /dev/null +++ b/packages/account-sdk/src/ui/ProlinkDialog/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +export { ProlinkDialog, showProlinkDialog } from './ProlinkDialog.js'; +export type { ProlinkDialogProps } from './ProlinkDialog.js'; + diff --git a/yarn.lock b/yarn.lock index fc23ab08..1067f77e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -261,6 +261,7 @@ __metadata: version: 0.0.0-use.local resolution: "@base-org/account@workspace:packages/account-sdk" dependencies: + "@bufbuild/protobuf": "npm:^1.7.0" "@coinbase/cdp-sdk": "npm:^1.0.0" "@noble/hashes": "npm:1.4.0" "@rollup/plugin-commonjs": "npm:^25.0.7" @@ -272,8 +273,10 @@ __metadata: "@testing-library/jest-dom": "npm:^6.5.0" "@testing-library/preact": "npm:^3.2.4" "@types/node": "npm:^14.18.54" + "@types/qrcode": "npm:^1.5.5" "@vitest/coverage-v8": "npm:2.1.2" "@vitest/web-worker": "npm:3.2.1" + brotli-wasm: "npm:^3.0.0" clsx: "npm:1.2.1" eventemitter3: "npm:5.0.1" fake-indexeddb: "npm:^6.0.0" @@ -284,6 +287,7 @@ __metadata: nodemon: "npm:^3.1.0" ox: "npm:0.6.9" preact: "npm:10.24.2" + qrcode: "npm:^1.5.3" rollup: "npm:^4.9.6" rollup-plugin-terser: "npm:^7.0.2" sass: "npm:^1.64.1" @@ -395,6 +399,13 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^1.7.0": + version: 1.10.1 + resolution: "@bufbuild/protobuf@npm:1.10.1" + checksum: 10/402e8d093d97eb9ea28bb65a667125cf20842f1d88767b36659a6d970222eb9c96c01c03a3429fbbaf1f40cde545d40a10c19b14741ef3cea29b98ad2f7109fa + languageName: node + linkType: hard + "@chakra-ui/accordion@npm:2.3.1": version: 2.3.1 resolution: "@chakra-ui/accordion@npm:2.3.1" @@ -3626,6 +3637,15 @@ __metadata: languageName: node linkType: hard +"@types/qrcode@npm:^1.5.5": + version: 1.5.5 + resolution: "@types/qrcode@npm:1.5.5" + dependencies: + "@types/node": "npm:*" + checksum: 10/a25686339bd2718e6a93943e7807ed68dd9c74a9da28aa77212086ee0ce9a173c0a232af9e3f6835acd09938dfc8a0f98c6bccf1a6c6a905fb003ab07f9e08f2 + languageName: node + linkType: hard + "@types/react@npm:18.2.15": version: 18.2.15 resolution: "@types/react@npm:18.2.15" @@ -4649,6 +4669,13 @@ __metadata: languageName: node linkType: hard +"brotli-wasm@npm:^3.0.0": + version: 3.0.1 + resolution: "brotli-wasm@npm:3.0.1" + checksum: 10/8d400459eea945cd66008ced3298efb24f65490d9cfd4953bfed088f4212aae0ff52bb3bd53970728b2cb29953ba25de02a2862d76f4fe74ba37befe265c5402 + languageName: node + linkType: hard + "browserslist@npm:^4.21.10": version: 4.23.3 resolution: "browserslist@npm:4.23.3" @@ -4789,6 +4816,13 @@ __metadata: languageName: node linkType: hard +"camelcase@npm:^5.0.0": + version: 5.3.1 + resolution: "camelcase@npm:5.3.1" + checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b + languageName: node + linkType: hard + "caniuse-lite@npm:^1.0.30001579": version: 1.0.30001673 resolution: "caniuse-lite@npm:1.0.30001673" @@ -4937,6 +4971,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^6.0.0": + version: 6.0.0 + resolution: "cliui@npm:6.0.0" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^6.2.0" + checksum: 10/44afbcc29df0899e87595590792a871cd8c4bc7d6ce92832d9ae268d141a77022adafca1aeaeccff618b62a613b8354e57fe22a275c199ec04baf00d381ef6ab + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -5205,6 +5250,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.4.3": version: 10.4.3 resolution: "decimal.js@npm:10.4.3" @@ -5327,6 +5379,13 @@ __metadata: languageName: node linkType: hard +"dijkstrajs@npm:^1.0.1": + version: 1.0.3 + resolution: "dijkstrajs@npm:1.0.3" + checksum: 10/0d8429699a6d5897ed371de494ef3c7072e8052b42abbd978e686a9b8689e70af005fa3e93e93263ee3653673ff5f89c36db830a57ae7c2e088cb9c496307507 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -5998,6 +6057,16 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^4.1.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: "npm:^5.0.0" + path-exists: "npm:^4.0.0" + checksum: 10/4c172680e8f8c1f78839486e14a43ef82e9decd0e74145f40707cc42e7420506d5ec92d9a11c22bd2c48fb0c384ea05dd30e10dd152fefeec6f2f75282a8b844 + languageName: node + linkType: hard + "focus-lock@npm:^1.3.5": version: 1.3.5 resolution: "focus-lock@npm:1.3.5" @@ -6159,7 +6228,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -7151,6 +7220,15 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: "npm:^4.1.0" + checksum: 10/83e51725e67517287d73e1ded92b28602e3ae5580b301fe54bfb76c0c723e3f285b19252e375712316774cf52006cb236aed5704692c32db0d5d089b69696e30 + languageName: node + linkType: hard + "lodash.mergewith@npm:4.6.2": version: 4.6.2 resolution: "lodash.mergewith@npm:4.6.2" @@ -7860,6 +7938,24 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: "npm:^2.0.0" + checksum: 10/84ff17f1a38126c3314e91ecfe56aecbf36430940e2873dadaa773ffe072dc23b7af8e46d4b6485d302a11673fe94c6b67ca2cfbb60c989848b02100d0594ac1 + languageName: node + linkType: hard + +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: "npm:^2.2.0" + checksum: 10/513bd14a455f5da4ebfcb819ef706c54adb09097703de6aeaa5d26fe5ea16df92b48d1ac45e01e3944ce1e6aa2a66f7f8894742b8c9d6e276e16cd2049a2b870 + languageName: node + linkType: hard + "p-map@npm:^4.0.0": version: 4.0.0 resolution: "p-map@npm:4.0.0" @@ -7869,6 +7965,13 @@ __metadata: languageName: node linkType: hard +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: 10/f8a8e9a7693659383f06aec604ad5ead237c7a261c18048a6e1b5b85a5f8a067e469aa24f5bc009b991ea3b058a87f5065ef4176793a200d4917349881216cae + languageName: node + linkType: hard + "pac-proxy-agent@npm:^7.0.1": version: 7.0.2 resolution: "pac-proxy-agent@npm:7.0.2" @@ -7932,6 +8035,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 10/505807199dfb7c50737b057dd8d351b82c033029ab94cb10a657609e00c1bc53b951cfdbccab8de04c5584d5eff31128ce6afd3db79281874a5ef2adbba55ed1 + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -8045,6 +8155,13 @@ __metadata: languageName: node linkType: hard +"pngjs@npm:^5.0.0": + version: 5.0.0 + resolution: "pngjs@npm:5.0.0" + checksum: 10/345781644740779752505af2fea3e9043f6c7cc349b18e1fb8842796360d1624791f0c24d33c0f27b05658373f90ffaa177a849e932e5fea1f540cef3975f3c9 + languageName: node + linkType: hard + "pony-cause@npm:^2.1.10": version: 2.1.11 resolution: "pony-cause@npm:2.1.11" @@ -8234,6 +8351,19 @@ __metadata: languageName: node linkType: hard +"qrcode@npm:^1.5.3": + version: 1.5.4 + resolution: "qrcode@npm:1.5.4" + dependencies: + dijkstrajs: "npm:^1.0.1" + pngjs: "npm:^5.0.0" + yargs: "npm:^15.3.1" + bin: + qrcode: bin/qrcode + checksum: 10/9a1b61760e4ea334545a0f54bbc11c537aba0a17cf52cab9fa1b07f8a1337eed0bc6f7fde41b197f2c82c249bc48728983bfaf861bb7ecb29dc597b2ae33c424 + languageName: node + linkType: hard + "queue-lit@npm:^1.5.1": version: 1.5.2 resolution: "queue-lit@npm:1.5.2" @@ -8457,6 +8587,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -8911,6 +9048,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.1": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -10193,6 +10337,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.13": version: 1.1.15 resolution: "which-typed-array@npm:1.1.15" @@ -10251,6 +10402,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^6.2.0": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10/0d64f2d438e0b555e693b95aee7b2689a12c3be5ac458192a1ce28f542a6e9e59ddfecc37520910c2c88eb1f82a5411260566dba5064e8f9895e76e169e76187 + languageName: node + linkType: hard + "wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -10358,6 +10520,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -10379,6 +10548,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^18.1.2": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/235bcbad5b7ca13e5abc54df61d42f230857c6f83223a38e4ed7b824681875b7f8b6ed52139d88a3ad007050f28dc0324b3c805deac7db22ae3b4815dae0e1bf + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -10401,6 +10580,25 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^15.3.1": + version: 15.4.1 + resolution: "yargs@npm:15.4.1" + dependencies: + cliui: "npm:^6.0.0" + decamelize: "npm:^1.2.0" + find-up: "npm:^4.1.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^4.2.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^18.1.2" + checksum: 10/bbcc82222996c0982905b668644ca363eebe6ffd6a572fbb52f0c0e8146661d8ce5af2a7df546968779bb03d1e4186f3ad3d55dfaadd1c4f0d5187c0e3a5ba16 + languageName: node + linkType: hard + "yauzl@npm:^2.10.0": version: 2.10.0 resolution: "yauzl@npm:2.10.0" From 38c3ebfe4c87bb6db9f561d9bfabb21cf0393cb7 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Thu, 16 Oct 2025 17:18:49 -0600 Subject: [PATCH 02/12] prolink playground --- examples/testapp/src/components/Layout.tsx | 33 +- .../pages/prolink-playground/index.page.tsx | 594 ++++++++++++++++++ .../prolink/utils/compression.ts | 8 +- .../src/ui/ProlinkDialog/ProlinkDialog.tsx | 128 +++- 4 files changed, 719 insertions(+), 44 deletions(-) create mode 100644 examples/testapp/src/pages/prolink-playground/index.page.tsx diff --git a/examples/testapp/src/components/Layout.tsx b/examples/testapp/src/components/Layout.tsx index e963749e..457860ec 100644 --- a/examples/testapp/src/components/Layout.tsx +++ b/examples/testapp/src/components/Layout.tsx @@ -1,21 +1,21 @@ import { CheckIcon, ChevronDownIcon } from '@chakra-ui/icons'; import { - Box, - Button, - Container, - Drawer, - DrawerBody, - DrawerContent, - DrawerHeader, - DrawerOverlay, - Flex, - Heading, - Menu, - MenuButton, - MenuItem, - MenuList, - useBreakpointValue, - useDisclosure, + Box, + Button, + Container, + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, + DrawerOverlay, + Flex, + Heading, + Menu, + MenuButton, + MenuItem, + MenuList, + useBreakpointValue, + useDisclosure, } from '@chakra-ui/react'; import NextLink from 'next/link'; import React, { useMemo } from 'react'; @@ -36,6 +36,7 @@ const PAGES = [ '/payment', '/pay-playground', '/subscribe-playground', + '/prolink-playground', ]; export function Layout({ children }: LayoutProps) { diff --git a/examples/testapp/src/pages/prolink-playground/index.page.tsx b/examples/testapp/src/pages/prolink-playground/index.page.tsx new file mode 100644 index 00000000..677c1fc9 --- /dev/null +++ b/examples/testapp/src/pages/prolink-playground/index.page.tsx @@ -0,0 +1,594 @@ +import { decodeProlink, encodeProlink, showProlinkDialog } from '@base-org/account'; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Button, + Code, + Container, + Divider, + FormControl, + FormLabel, + HStack, + Heading, + Input, + Select, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + Textarea, + VStack, + useColorModeValue, + useToast, +} from '@chakra-ui/react'; +import { useState } from 'react'; + +export default function ProlinkPlayground() { + const toast = useToast(); + + // Color mode values + const bgColor = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.600'); + const codeBgColor = useColorModeValue('gray.50', 'gray.900'); + + // Method selection + const [methodType, setMethodType] = useState<'wallet_sendCalls' | 'wallet_sign' | 'generic'>( + 'wallet_sendCalls' + ); + + // Common fields + const [chainId, setChainId] = useState('8453'); // Base mainnet + + // wallet_sendCalls fields + const [callsTo, setCallsTo] = useState('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'); // USDC on Base + const [callsData, setCallsData] = useState( + '0xa9059cbb000000000000000000000000fe21034794a5a574b94fe4fdfd16e005f1c96e5100000000000000000000000000000000000000000000000000000000004c4b40' + ); // ERC20 transfer + const [callsValue, setCallsValue] = useState('0x0'); + const [callsVersion, setCallsVersion] = useState('1.0'); + + // wallet_sign fields (SpendPermission example) + const [signVersion, setSignVersion] = useState('1'); + const [signChainId, setSignChainId] = useState('84532'); // Base Sepolia + const [spAccount, setSpAccount] = useState('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + const [spSpender, setSpSpender] = useState('0x8d9F34934dc9619e5DC3Df27D0A40b4A744E7eAa'); + const [spToken, setSpToken] = useState('0x036CbD53842c5426634e7929541eC2318f3dCF7e'); + const [spAllowance, setSpAllowance] = useState('0x2710'); + const [spPeriod, setSpPeriod] = useState('281474976710655'); + const [spStart, setSpStart] = useState('0'); + const [spEnd, setSpEnd] = useState('1914749767655'); + const [spSalt, setSpSalt] = useState( + '0x2d6688aae9435fb91ab0a1fe7ea54ec3ffd86e8e18a0c17e1923c467dea4b75f' + ); + const [spVerifyingContract, setSpVerifyingContract] = useState( + '0xf85210b21cc50302f477ba56686d2019dc9b67ad' + ); + + // Generic JSON-RPC fields + const [genericMethod, setGenericMethod] = useState('eth_sendTransaction'); + const [genericParams, setGenericParams] = useState( + JSON.stringify( + [ + { + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + value: '0x100', + data: '0x', + }, + ], + null, + 2 + ) + ); + + // Capabilities + const [useCapabilities, setUseCapabilities] = useState(false); + const [capabilitiesJson, setCapabilitiesJson] = useState( + JSON.stringify( + { + dataCallback: { + callbackURL: 'https://example.com/callback', + events: ['initiated', 'postSign'], + }, + }, + null, + 2 + ) + ); + + // Results + const [loading, setLoading] = useState(false); + const [encodedPayload, setEncodedPayload] = useState(''); + const [error, setError] = useState(null); + const [decodedResult, setDecodedResult] = useState(null); + + const generateProlink = async () => { + setLoading(true); + setError(null); + setEncodedPayload(''); + setDecodedResult(null); + + try { + let request: { + method: string; + params: unknown; + chainId?: number; + capabilities?: Record; + }; + + if (methodType === 'wallet_sendCalls') { + request = { + method: 'wallet_sendCalls', + params: [ + { + version: callsVersion, + chainId: `0x${Number.parseInt(chainId).toString(16)}`, + calls: [ + { + to: callsTo, + data: callsData, + value: callsValue, + }, + ], + }, + ], + }; + } else if (methodType === 'wallet_sign') { + request = { + method: 'wallet_sign', + params: [ + { + version: signVersion, + chainId: `0x${Number.parseInt(signChainId).toString(16)}`, + type: '0x01', + data: { + types: { + SpendPermission: [ + { name: 'account', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'allowance', type: 'uint160' }, + { name: 'period', type: 'uint48' }, + { name: 'start', type: 'uint48' }, + { name: 'end', type: 'uint48' }, + { name: 'salt', type: 'uint256' }, + { name: 'extraData', type: 'bytes' }, + ], + }, + domain: { + name: 'Spend Permission Manager', + version: '1', + chainId: Number.parseInt(signChainId), + verifyingContract: spVerifyingContract, + }, + primaryType: 'SpendPermission', + message: { + account: spAccount, + spender: spSpender, + token: spToken, + allowance: spAllowance, + period: Number.parseInt(spPeriod), + start: Number.parseInt(spStart), + end: Number.parseInt(spEnd), + salt: spSalt, + extraData: '0x', + }, + }, + }, + ], + }; + } else { + // generic + request = { + method: genericMethod, + params: JSON.parse(genericParams), + chainId: Number.parseInt(chainId), + }; + } + + // Add capabilities if enabled + if (useCapabilities) { + try { + request.capabilities = JSON.parse(capabilitiesJson); + } catch (e) { + throw new Error(`Invalid capabilities JSON: ${e instanceof Error ? e.message : 'unknown'}`); + } + } + + // Encode the prolink + const payload = await encodeProlink(request); + setEncodedPayload(payload); + + // Decode to verify + const decoded = await decodeProlink(payload); + setDecodedResult(decoded); + + // Show the SDK modal with QR code + showProlinkDialog(payload, { + title: 'Scan to Connect', + }); + + toast({ + title: 'Prolink generated!', + description: `Payload size: ${payload.length} characters`, + status: 'success', + duration: 3000, + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error'; + setError(errorMessage); + toast({ + title: 'Error generating prolink', + description: errorMessage, + status: 'error', + duration: 5000, + }); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(encodedPayload); + toast({ + title: 'Copied!', + description: 'Payload copied to clipboard', + status: 'success', + duration: 2000, + }); + }; + + return ( + + + Prolink URI Generator + + Generate compressed, URL-safe payloads for wallet_sendCalls, wallet_sign, and generic + JSON-RPC requests + + + {/* Method Selection */} + + + + Method Type + + + + + + {/* Method-specific fields */} + {methodType === 'wallet_sendCalls' && ( + + wallet_sendCalls Parameters + + Chain ID + + + + Version + setCallsVersion(e.target.value)} /> + + + To Address + setCallsTo(e.target.value)} + placeholder="0x..." + /> + + + Data (hex) +