Add dataSuffix to pay() with input validation (#254)

* Add dataSuffix support to pay() and attribution validation

- Add optional `dataSuffix` field to `PaymentOptions` type (0x-prefixed hex)
- Thread dataSuffix through pay() -> executePaymentWithSDK -> createEphemeralSDK
- Set attribution.dataSuffix on SDK preference when dataSuffix is provided
- Add validateDataSuffix() to both sdkManager and base-account utils
- Remove fixed 16-byte length constraint from makeDataSuffix
- Update README and payment README with dataSuffix usage examples
- Add test coverage for dataSuffix passthrough and validation

Made-with: Cursor

* chore: apply formatter fix to pay.ts destructuring

Made-with: Cursor

* chore: remove 16-byte dataSuffix reference from testapp SDKConfig

Made-with: Cursor

* format

* unify type to viem Hex
This commit is contained in:
Spencer Stock
2026-03-04 03:11:37 -07:00
committed by GitHub
parent 2f1a2a23bb
commit 3e96c7152d
11 changed files with 109 additions and 16 deletions
+1
View File
@@ -37,6 +37,7 @@ import { pay } from '@base-org/account';
const payment = await pay({
amount: "10.50", // Amount in USDC
to: "0xYourWalletAddress", // Your wallet address
dataSuffix: "0xabc123", // Optional attribution data suffix
testnet: true // Use testnet for testing
});
@@ -96,8 +96,8 @@ export function SDKConfig() {
<Code ml={2}>attribution.dataSuffix</Code>
</Flex>
<Text mt={2} fontSize="sm">
First 16 bytes of a unique string to identify your onchain activity. Update the text
box below to have your data suffix applied
A 0x-prefixed hex string to identify your onchain activity. Update the text box
below to have your data suffix applied
</Text>
<FormControl mt={2}>
<Input
@@ -59,7 +59,7 @@ export type Attribution =
}
| {
auto?: never;
dataSuffix: `0x${string}`;
dataSuffix: Hex;
};
export type Preference = {
@@ -74,8 +74,7 @@ export type Preference = {
* @type {Attribution}
* @note Smart Wallet only
* @description This option only applies to Coinbase Smart Wallet. When a valid data suffix is supplied, it is appended to the initCode and executeBatch calldata.
* Coinbase Smart Wallet expects a 16 byte hex string. If the data suffix is not a 16 byte hex string, the Smart Wallet will ignore the property. If auto is true,
* the Smart Wallet will generate a 16 byte hex string from the apps origin.
* If auto is true, the Smart Wallet will generate a deterministic hex suffix from the app's origin.
*/
attribution?: Attribution;
/**
@@ -3,6 +3,7 @@ import { store } from ':store/store.js';
import * as checkCrossOriginModule from ':util/checkCrossOriginOpenerPolicy.js';
import * as validatePreferencesModule from ':util/validatePreferences.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Hex } from 'viem';
import { BaseAccountProvider } from './BaseAccountProvider.js';
import { CreateProviderOptions, createBaseAccountSDK } from './createBaseAccountSDK.js';
import * as getInjectedProviderModule from './getInjectedProvider.js';
@@ -491,7 +492,7 @@ describe('createProvider', () => {
it('should handle complex nested preference objects', () => {
const complexPreference = {
attribution: { dataSuffix: '0x1234567890123456' as `0x${string}` },
attribution: { dataSuffix: '0x1234567890123456' as Hex },
telemetry: false,
customProperty: 'custom value',
};
@@ -11,6 +11,7 @@ import { pay } from '@base-org/account';
const payment = await pay({
amount: "10.50",
to: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51",
dataSuffix: "0xabc123",
testnet: true
});
@@ -119,6 +120,7 @@ const status = await getPaymentStatus({
- `amount: string` - Amount of USDC to send as a string (e.g., "10.50")
- `to: string` - Ethereum address to send payment to
- `dataSuffix?: Hex` - Optional attribution data suffix as 0x-prefixed hex (viem `Hex` type)
- `testnet?: boolean` - Whether to use Base Sepolia testnet (default: false)
- `payerInfo?: PayerInfo` - Optional payer information configuration for data callbacks
- `telemetry?: boolean` - Whether to enable telemetry logging (default: true)
@@ -339,7 +339,8 @@ describe('pay', () => {
expect.any(Object),
false,
undefined,
false
false,
undefined
);
});
@@ -387,7 +388,8 @@ describe('pay', () => {
}),
true,
undefined,
true
true,
undefined
);
});
@@ -462,6 +464,33 @@ describe('pay', () => {
);
});
it('should pass dataSuffix through to SDK manager', async () => {
vi.mocked(validation.validateStringAmount).mockReturnValue(undefined);
vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({
version: '2.0.0',
chainId: 8453,
calls: [],
capabilities: {},
});
vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({
transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
});
await pay({
amount: '10.50',
to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51',
dataSuffix: '0xabc123',
});
expect(sdkManager.executePaymentWithSDK).toHaveBeenCalledWith(
expect.any(Object),
false,
undefined,
true,
'0xabc123'
);
});
it('should handle different error types and normalize them', async () => {
// Test with string error
vi.mocked(validation.validateStringAmount).mockImplementation(() => {
@@ -14,6 +14,7 @@ import { normalizeAddress, validateStringAmount } from './utils/validation.js';
* @param options - Payment options
* @param options.amount - Amount of USDC to send as a string (e.g., "10.50")
* @param options.to - Ethereum address to send payment to
* @param options.dataSuffix - Optional 0x-prefixed hex attribution suffix
* @param options.testnet - Whether to use Base Sepolia testnet (default: false)
* @param options.payerInfo - Optional payer information configuration for data callbacks
* @returns Promise<PaymentResult> - Result of the payment transaction
@@ -35,7 +36,15 @@ import { normalizeAddress, validateStringAmount } from './utils/validation.js';
* ```
*/
export async function pay(options: PaymentOptions): Promise<PaymentResult> {
const { amount, to, testnet = false, payerInfo, walletUrl, telemetry = true } = options;
const {
amount,
to,
dataSuffix,
testnet = false,
payerInfo,
walletUrl,
telemetry = true,
} = options;
// Generate correlation ID for this payment request
const correlationId = crypto.randomUUID();
@@ -62,7 +71,8 @@ export async function pay(options: PaymentOptions): Promise<PaymentResult> {
requestParams,
testnet,
walletUrl,
telemetry
telemetry,
dataSuffix
);
// Log payment completed
@@ -61,6 +61,11 @@ export interface PaymentOptions {
amount: string;
/** Ethereum address to send payment to */
to: string;
/**
* Optional attribution data suffix to append to wallet call data.
* Must be a 0x-prefixed hex string (e.g., "0xabc123").
*/
dataSuffix?: Hex;
/** Whether to use testnet (Base Sepolia). Defaults to false (mainnet) */
testnet?: boolean;
/** Optional payer information configuration for data callbacks */
@@ -41,9 +41,15 @@ export interface PaymentExecutionResult {
* @param chainId - The chain ID to use
* @param walletUrl - Optional wallet URL to use
* @param telemetry - Whether to enable telemetry (defaults to true)
* @param dataSuffix - Optional attribution data suffix
* @returns The configured SDK instance
*/
export function createEphemeralSDK(chainId: number, walletUrl?: string, telemetry: boolean = true) {
export function createEphemeralSDK(
chainId: number,
walletUrl?: string,
telemetry: boolean = true,
dataSuffix?: Hex
) {
const appName = typeof window !== 'undefined' ? window.location.origin : 'Base Pay SDK';
const sdk = createBaseAccountSDK({
@@ -52,6 +58,7 @@ export function createEphemeralSDK(chainId: number, walletUrl?: string, telemetr
preference: {
telemetry: telemetry,
walletUrl,
attribution: dataSuffix ? { dataSuffix } : undefined,
},
});
@@ -118,18 +125,20 @@ export async function executePayment(
* @param testnet - Whether to use testnet
* @param walletUrl - Optional wallet URL to use
* @param telemetry - Whether to enable telemetry (defaults to true)
* @param dataSuffix - Optional attribution data suffix
* @returns The payment execution result
*/
export async function executePaymentWithSDK(
requestParams: WalletSendCallsRequestParams,
testnet: boolean,
walletUrl?: string,
telemetry: boolean = true
telemetry: boolean = true,
dataSuffix?: Hex
): Promise<PaymentExecutionResult> {
const network = testnet ? 'baseSepolia' : 'base';
const chainId = CHAIN_IDS[network];
const sdk = createEphemeralSDK(chainId, walletUrl, telemetry);
const sdk = createEphemeralSDK(chainId, walletUrl, telemetry, dataSuffix);
const provider = sdk.getProvider();
try {
@@ -15,6 +15,7 @@ import {
initSubAccountConfig,
injectRequestCapabilities,
isSendCallsParams,
makeDataSuffix,
prependWithoutDuplicates,
requestHasCapability,
} from './utils.js';
@@ -57,6 +58,35 @@ describe('utils', () => {
});
});
describe('makeDataSuffix', () => {
it('throws for suffixes without a 0x prefix', () => {
expect(() =>
makeDataSuffix({
attribution: { dataSuffix: 'abc123' as any },
dappOrigin: 'https://example.com',
})
).toThrow('Invalid dataSuffix: expected a 0x-prefixed hex string');
});
it('accepts suffixes with a 0x prefix', () => {
expect(
makeDataSuffix({
attribution: { dataSuffix: '0xabc123' },
dappOrigin: 'https://example.com',
})
).toBe('0xabc123');
});
it('does not enforce a fixed 16-byte suffix length', () => {
expect(
makeDataSuffix({
attribution: { dataSuffix: '0x1234567890abcdef1234567890abcdef1234567890abcdef' },
dappOrigin: 'https://example.com',
})
).toBe('0x1234567890abcdef1234567890abcdef1234567890abcdef');
});
});
describe('assertParamsChainId', () => {
it('should throw if the params are not an array', () => {
expect(() => assertParamsChainId({})).toThrow();
@@ -1,7 +1,7 @@
import { PublicClient, WalletSendCallsParameters, hexToBigInt, isAddress } from 'viem';
import { InsufficientBalanceErrorData } from ':core/error/errors.js';
import { Hex, keccak256, numberToHex, slice, toHex } from 'viem';
import { Hex, isHex, keccak256, numberToHex, slice, toHex } from 'viem';
import { standardErrors } from ':core/error/errors.js';
import { Attribution, RequestArguments } from ':core/provider/interface.js';
@@ -523,6 +523,13 @@ export function compute16ByteHash(input: string): Hex {
return slice(keccak256(toHex(input)), 0, 16);
}
export function validateDataSuffix(dataSuffix: string): Hex {
if (!isHex(dataSuffix)) {
throw new Error('Invalid dataSuffix: expected a 0x-prefixed hex string');
}
return dataSuffix;
}
export function makeDataSuffix({
attribution,
dappOrigin,
@@ -535,8 +542,8 @@ export function makeDataSuffix({
return compute16ByteHash(dappOrigin);
}
if ('dataSuffix' in attribution) {
return attribution.dataSuffix;
if ('dataSuffix' in attribution && typeof attribution.dataSuffix === 'string') {
return validateDataSuffix(attribution.dataSuffix);
}
return;