From 5a662045ef9d3a3005552b156bec7bc1cbaa761e Mon Sep 17 00:00:00 2001 From: rahimatonize Date: Mon, 27 Apr 2026 08:45:42 +0100 Subject: [PATCH] feat: Add typed decoder utilities for contract return values - Implement comprehensive decoder library with 30+ decoders - Add primitive decoders (string, number, bigint, boolean, bytes, address) - Add composite decoders (array, tuple, struct, map, option) - Add Soroban-specific decoders (u32, u64, u128, i32, i64, i128, BytesN) - Add utility decoders (transform, validate, default, oneOf, enum, literal) - Add pre-built contract decoders (Event, TicketTier, BuyerPurchase, TBA Token) - Include safe decoding with result types - Add comprehensive test suite with 50+ test cases - Include detailed documentation and 15 usage examples Resolves #221 --- soroban-client/sdk/DECODERS.md | 732 ++++++++++++++++++ soroban-client/sdk/IMPLEMENTATION_SUMMARY.md | 304 ++++++++ soroban-client/sdk/README.md | 31 + soroban-client/sdk/examples/decoder-usage.ts | 542 +++++++++++++ .../sdk/src/__tests__/decoders.test.ts | 548 +++++++++++++ soroban-client/sdk/src/decoders.ts | 621 +++++++++++++++ soroban-client/sdk/src/index.ts | 1 + 7 files changed, 2779 insertions(+) create mode 100644 soroban-client/sdk/DECODERS.md create mode 100644 soroban-client/sdk/IMPLEMENTATION_SUMMARY.md create mode 100644 soroban-client/sdk/examples/decoder-usage.ts create mode 100644 soroban-client/sdk/src/__tests__/decoders.test.ts create mode 100644 soroban-client/sdk/src/decoders.ts diff --git a/soroban-client/sdk/DECODERS.md b/soroban-client/sdk/DECODERS.md new file mode 100644 index 00000000..04a56e90 --- /dev/null +++ b/soroban-client/sdk/DECODERS.md @@ -0,0 +1,732 @@ +# Typed Decoder Utilities for Soroban Contract Responses + +This document describes the typed decoder utilities for safely parsing Soroban contract return values in TypeScript. + +## Overview + +The decoder utilities provide a type-safe way to parse and validate contract responses from Soroban smart contracts. Instead of relying on unsafe type assertions, decoders explicitly validate data structure and types, catching errors early and providing clear error messages. + +## Features + +- **Type Safety**: Explicit type validation with TypeScript support +- **Composable**: Build complex decoders from simple ones +- **Error Handling**: Clear error messages with context +- **Soroban Types**: Built-in support for Soroban-specific types (u32, u64, u128, i128, etc.) +- **Contract Decoders**: Pre-built decoders for common contract structures +- **Safe Decoding**: Optional safe decoding that returns results instead of throwing + +## Installation + +The decoders are included in the SDK: + +```typescript +import { + decodeString, + decodeNumber, + decodeArray, + ContractDecoder, + // ... other decoders +} from "@crowdpass/tokenbound-sdk"; +``` + +## Basic Usage + +### Primitive Types + +```typescript +import { decodeString, decodeNumber, decodeBigInt, decodeBoolean } from "@crowdpass/tokenbound-sdk"; + +// Decode primitives +const name = decodeString("Alice"); // "Alice" +const age = decodeNumber(25); // 25 +const balance = decodeBigInt("1000000000"); // 1000000000n +const active = decodeBoolean(true); // true +``` + +### Arrays + +```typescript +import { decodeArray, decodeNumber, decodeString } from "@crowdpass/tokenbound-sdk"; + +// Decode array of numbers +const numbers = decodeArray(decodeNumber)([1, 2, 3, 4, 5]); +// [1, 2, 3, 4, 5] + +// Decode array of strings +const names = decodeArray(decodeString)(["Alice", "Bob", "Charlie"]); +// ["Alice", "Bob", "Charlie"] +``` + +### Optional Values + +```typescript +import { decodeOption, decodeNumber } from "@crowdpass/tokenbound-sdk"; + +// Decode optional number +const maybeNumber = decodeOption(decodeNumber); + +maybeNumber(null); // null +maybeNumber(undefined); // null +maybeNumber({ Some: 42 }); // 42 (Soroban Option format) +maybeNumber(42); // 42 (direct value) +``` + +### Structs/Objects + +```typescript +import { decodeStruct, decodeNumber, decodeString, decodeBoolean } from "@crowdpass/tokenbound-sdk"; + +// Define a struct decoder +const decodeUser = decodeStruct({ + id: decodeNumber, + name: decodeString, + email: decodeString, + active: decodeBoolean, +}); + +// Use it +const user = decodeUser({ + id: 1, + name: "Alice", + email: "alice@example.com", + active: true, +}); +// { id: 1, name: "Alice", email: "alice@example.com", active: true } +``` + +### Tuples + +```typescript +import { decodeTuple, decodeNumber, decodeString, decodeBoolean } from "@crowdpass/tokenbound-sdk"; + +// Decode tuple with mixed types +const decodeMixedTuple = decodeTuple( + decodeNumber, + decodeString, + decodeBoolean +); + +const result = decodeMixedTuple([42, "hello", true]); +// [42, "hello", true] +``` + +## Soroban-Specific Types + +### Unsigned Integers + +```typescript +import { decodeU32, decodeU64, decodeU128 } from "@crowdpass/tokenbound-sdk"; + +// u32: 0 to 4,294,967,295 +const eventId = decodeU32(123); + +// u64: 0 to 2^64-1 +const timestamp = decodeU64(1234567890); + +// u128: 0 to 2^128-1 (as bigint) +const totalSupply = decodeU128(1000000000000n); +``` + +### Signed Integers + +```typescript +import { decodeI32, decodeI64, decodeI128 } from "@crowdpass/tokenbound-sdk"; + +// i32: -2,147,483,648 to 2,147,483,647 +const temperature = decodeI32(-15); + +// i64: -2^63 to 2^63-1 +const balance = decodeI64(-1000); + +// i128: -2^127 to 2^127-1 (as bigint) +const price = decodeI128(-5000000000n); +``` + +### Addresses + +```typescript +import { decodeAddress } from "@crowdpass/tokenbound-sdk"; + +// Stellar address (G... or C...) +const organizer = decodeAddress("GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); +const contract = decodeAddress("CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); +``` + +### Bytes + +```typescript +import { decodeBytes, decodeBytesN } from "@crowdpass/tokenbound-sdk"; + +// Variable-length bytes +const data = decodeBytes("0x010203"); +// Uint8Array([1, 2, 3]) + +// Fixed-length bytes (e.g., BytesN<32>) +const hash = decodeBytesN(32)("0x" + "00".repeat(32)); +// Uint8Array of length 32 +``` + +## Contract-Specific Decoders + +### Event Decoder + +```typescript +import { ContractDecoder } from "@crowdpass/tokenbound-sdk"; + +const decodeEvent = ContractDecoder.event(); + +const event = decodeEvent({ + id: 1, + theme: "Web3 Conference", + organizer: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + event_type: "Conference", + total_tickets: 100n, + tickets_sold: 50n, + ticket_price: 1000000000n, + start_date: 1234567890, + end_date: 1234567900, + is_canceled: false, + ticket_nft_addr: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + payment_token: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", +}); +``` + +### Ticket Tier Decoder + +```typescript +import { ContractDecoder } from "@crowdpass/tokenbound-sdk"; + +const decodeTier = ContractDecoder.ticketTier(); + +const tier = decodeTier({ + name: "VIP", + price: 5000000000n, + total_quantity: 50n, + sold_quantity: 25n, +}); +``` + +### Buyer Purchase Decoder + +```typescript +import { ContractDecoder } from "@crowdpass/tokenbound-sdk"; + +const decodePurchase = ContractDecoder.buyerPurchase(); + +const purchase = decodePurchase({ + quantity: 2n, + total_paid: 2000000000n, +}); +``` + +### TBA Token Decoder + +```typescript +import { ContractDecoder } from "@crowdpass/tokenbound-sdk"; + +const decodeToken = ContractDecoder.tbaToken(); + +const token = decodeToken([ + 1, // chain_id + "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", // token_contract + 123n, // token_id +]); +// [1, "CXXX...", 123n] +``` + +## Advanced Usage + +### Composing Decoders + +```typescript +import { + decodeStruct, + decodeArray, + decodeOption, + decodeU32, + decodeString, + decodeI128, +} from "@crowdpass/tokenbound-sdk"; + +// Decode complex nested structure +const decodeEventWithTiers = decodeStruct({ + id: decodeU32, + name: decodeString, + tiers: decodeArray( + decodeStruct({ + name: decodeString, + price: decodeI128, + available: decodeOption(decodeU32), + }) + ), +}); + +const event = decodeEventWithTiers({ + id: 1, + name: "Conference", + tiers: [ + { name: "General", price: 1000000000n, available: 100 }, + { name: "VIP", price: 5000000000n, available: null }, + ], +}); +``` + +### Transform Decoded Values + +```typescript +import { decodeTransform, decodeString } from "@crowdpass/tokenbound-sdk"; + +// Decode and transform to uppercase +const decodeUppercase = decodeTransform( + decodeString, + (s) => s.toUpperCase() +); + +const result = decodeUppercase("hello"); +// "HELLO" + +// Decode number and multiply +const decodeDouble = decodeTransform( + decodeNumber, + (n) => n * 2 +); + +const doubled = decodeDouble(21); +// 42 +``` + +### Validate Decoded Values + +```typescript +import { decodeValidate, decodeNumber } from "@crowdpass/tokenbound-sdk"; + +// Decode and validate positive number +const decodePositive = decodeValidate( + decodeNumber, + (n) => n > 0, + "Must be positive" +); + +decodePositive(5); // 5 +decodePositive(-5); // throws DecoderError: Must be positive +``` + +### Decode with Default Value + +```typescript +import { decodeWithDefault, decodeNumber } from "@crowdpass/tokenbound-sdk"; + +// Decode with fallback +const decodeWithZero = decodeWithDefault(decodeNumber, 0); + +decodeWithZero(42); // 42 +decodeWithZero("invalid"); // 0 (fallback) +``` + +### Decode One of Multiple Types + +```typescript +import { decodeOneOf, decodeNumber, decodeString } from "@crowdpass/tokenbound-sdk"; + +// Try number first, then string +const decodeNumberOrString = decodeOneOf( + decodeNumber, + decodeString +); + +decodeNumberOrString(42); // 42 +decodeNumberOrString("hello"); // "hello" +``` + +### Decode Enum Values + +```typescript +import { decodeEnum } from "@crowdpass/tokenbound-sdk"; + +// Define enum decoder +const decodeEventType = decodeEnum( + ["Conference", "Concert", "Workshop", "Meetup"] as const, + "EventType" +); + +decodeEventType("Conference"); // "Conference" +decodeEventType("Invalid"); // throws DecoderError +``` + +### Decode Literal Values + +```typescript +import { decodeLiteral } from "@crowdpass/tokenbound-sdk"; + +// Decode exact value +const decodeSuccess = decodeLiteral("success"); + +decodeSuccess("success"); // "success" +decodeSuccess("failure"); // throws DecoderError +``` + +## Safe Decoding + +Instead of throwing errors, you can use safe decoding that returns a result: + +```typescript +import { safeDecode, decodeNumber } from "@crowdpass/tokenbound-sdk"; + +// Safe decode returns result object +const result = safeDecode(decodeNumber, "invalid"); + +if (result.success) { + console.log("Value:", result.value); +} else { + console.error("Error:", result.error.message); +} +``` + +## Error Handling + +### DecoderError + +All decoder errors are instances of `DecoderError`: + +```typescript +import { DecoderError, decodeNumber } from "@crowdpass/tokenbound-sdk"; + +try { + decodeNumber("not a number"); +} catch (error) { + if (error instanceof DecoderError) { + console.log("Message:", error.message); + console.log("Value:", error.value); + console.log("Expected:", error.expectedType); + } +} +``` + +### Error Context + +Add context to decoder errors: + +```typescript +import { withContext, decodeNumber } from "@crowdpass/tokenbound-sdk"; + +const decodeEventId = withContext(decodeNumber, "event ID"); + +try { + decodeEventId("invalid"); +} catch (error) { + // Error: event ID: Expected number, got string +} +``` + +### Contract Response Decoding + +Decode contract responses with automatic error handling: + +```typescript +import { decodeContractResponse, ContractDecoder } from "@crowdpass/tokenbound-sdk"; + +const response = await contract.getEvent(1); + +const event = decodeContractResponse( + ContractDecoder.event(), + response, + "getEvent" +); +``` + +## Integration with SDK + +### Using Decoders in Contract Methods + +```typescript +import { createTokenboundSdk, ContractDecoder } from "@crowdpass/tokenbound-sdk"; + +const sdk = createTokenboundSdk({ + // ... config +}); + +// Get raw response +const rawEvent = await sdk.eventManager.getEvent(1); + +// Decode with explicit decoder +const event = ContractDecoder.event()(rawEvent); +``` + +### Custom Contract Decoders + +Create decoders for your custom contracts: + +```typescript +import { decodeStruct, decodeU32, decodeString, decodeAddress } from "@crowdpass/tokenbound-sdk"; + +// Define custom contract response decoder +const decodeMyContractResponse = decodeStruct({ + id: decodeU32, + owner: decodeAddress, + metadata: decodeString, +}); + +// Use it +const response = await myContract.getData(); +const data = decodeMyContractResponse(response); +``` + +## Best Practices + +### 1. Define Decoders Once + +```typescript +// decoders.ts +export const decodeEvent = ContractDecoder.event(); +export const decodeTier = ContractDecoder.ticketTier(); + +// usage.ts +import { decodeEvent } from "./decoders"; +const event = decodeEvent(response); +``` + +### 2. Use Type Inference + +```typescript +import { decodeStruct, decodeNumber, decodeString } from "@crowdpass/tokenbound-sdk"; + +const decodeUser = decodeStruct({ + id: decodeNumber, + name: decodeString, +}); + +// TypeScript infers: { id: number; name: string } +type User = ReturnType; +``` + +### 3. Compose Complex Decoders + +```typescript +// Build from simple to complex +const decodeAddress = decodeString; +const decodeAddressList = decodeArray(decodeAddress); +const decodeEventWithAttendees = decodeStruct({ + id: decodeU32, + attendees: decodeAddressList, +}); +``` + +### 4. Handle Optional Fields + +```typescript +const decodeEventUpdate = decodeStruct({ + id: decodeU32, + theme: decodeOption(decodeString), + price: decodeOption(decodeI128), + tickets: decodeOption(decodeU128), +}); +``` + +### 5. Validate Business Logic + +```typescript +const decodePositivePrice = decodeValidate( + decodeI128, + (price) => price > 0n, + "Price must be positive" +); + +const decodeEvent = decodeStruct({ + id: decodeU32, + price: decodePositivePrice, +}); +``` + +## Performance Considerations + +### Decoder Reuse + +Decoders are pure functions and can be reused: + +```typescript +// Good: Define once, use many times +const decodeEvent = ContractDecoder.event(); +const events = responses.map(decodeEvent); + +// Avoid: Creating decoder in loop +responses.map(r => ContractDecoder.event()(r)); +``` + +### Early Validation + +Decoders validate early and fail fast: + +```typescript +// Fails immediately on first invalid field +const event = decodeEvent(response); +``` + +### Type Safety + +Decoders provide compile-time type safety: + +```typescript +const decodeUser = decodeStruct({ + id: decodeNumber, + name: decodeString, +}); + +const user = decodeUser(data); +// TypeScript knows: user.id is number, user.name is string +``` + +## Testing + +### Testing Decoders + +```typescript +import { decodeStruct, decodeNumber, decodeString } from "@crowdpass/tokenbound-sdk"; + +describe("User Decoder", () => { + const decodeUser = decodeStruct({ + id: decodeNumber, + name: decodeString, + }); + + it("should decode valid user", () => { + const user = decodeUser({ id: 1, name: "Alice" }); + expect(user).toEqual({ id: 1, name: "Alice" }); + }); + + it("should throw on invalid user", () => { + expect(() => decodeUser({ id: "invalid", name: "Alice" })) + .toThrow(DecoderError); + }); +}); +``` + +### Testing with Mock Data + +```typescript +const mockEvent = { + id: 1, + theme: "Test Event", + organizer: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + // ... other fields +}; + +const event = ContractDecoder.event()(mockEvent); +expect(event.id).toBe(1); +``` + +## Migration Guide + +### From Unsafe Type Assertions + +**Before:** +```typescript +const event = response as EventRecord; +// No validation, runtime errors possible +``` + +**After:** +```typescript +const event = ContractDecoder.event()(response); +// Validated, type-safe, clear errors +``` + +### From Manual Validation + +**Before:** +```typescript +function parseEvent(raw: any): EventRecord { + if (typeof raw.id !== "number") throw new Error("Invalid id"); + if (typeof raw.theme !== "string") throw new Error("Invalid theme"); + // ... many more checks + return raw as EventRecord; +} +``` + +**After:** +```typescript +const decodeEvent = ContractDecoder.event(); +const event = decodeEvent(raw); +``` + +## Troubleshooting + +### Common Errors + +**"Expected string, got number"** +- The value type doesn't match the decoder +- Check the contract return type + +**"Expected array, got object"** +- Using array decoder on non-array value +- Verify the contract response structure + +**"Struct field 'x': Expected number, got undefined"** +- Missing required field in response +- Check if field should be optional + +### Debugging + +Enable detailed error messages: + +```typescript +try { + const event = decodeEvent(response); +} catch (error) { + if (error instanceof DecoderError) { + console.log("Failed to decode:", error.message); + console.log("Value:", error.value); + console.log("Expected type:", error.expectedType); + } +} +``` + +## API Reference + +See the [full API documentation](./src/decoders.ts) for all available decoders and utilities. + +### Primitive Decoders +- `decodeString` +- `decodeNumber` +- `decodeBigInt` +- `decodeBoolean` +- `decodeBytes` +- `decodeAddress` +- `decodeSymbol` + +### Composite Decoders +- `decodeArray` +- `decodeVec` +- `decodeOption` +- `decodeTuple` +- `decodeStruct` +- `decodeMap` + +### Utility Decoders +- `decodeWithDefault` +- `decodeOneOf` +- `decodeTransform` +- `decodeValidate` +- `decodeLiteral` +- `decodeEnum` + +### Soroban Decoders +- `decodeU32`, `decodeU64`, `decodeU128` +- `decodeI32`, `decodeI64`, `decodeI128` +- `decodeBytesN` +- `decodeVoid` + +### Contract Decoders +- `ContractDecoder.event()` +- `ContractDecoder.ticketTier()` +- `ContractDecoder.buyerPurchase()` +- `ContractDecoder.tbaToken()` + +## Examples + +See [examples/decoder-usage.ts](./examples/decoder-usage.ts) for comprehensive examples. + +## References + +- [Soroban Documentation](https://soroban.stellar.org/docs) +- [Stellar SDK](https://stellar.github.io/js-stellar-sdk/) +- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html) diff --git a/soroban-client/sdk/IMPLEMENTATION_SUMMARY.md b/soroban-client/sdk/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 00000000..1be34c0f --- /dev/null +++ b/soroban-client/sdk/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,304 @@ +# Implementation Summary: Exponential Backoff and Retry Policy for RPC Calls + +## Issue +**#220**: Add exponential backoff and retry policy for RPC calls in soroban-client + +## Overview +Implemented a comprehensive retry policy system with exponential backoff and jitter to handle transient RPC failures in the Soroban client SDK. + +## Changes Made + +### 1. Core Retry Logic (`src/retry.ts`) +Created a new module with: + +- **`RetryConfig` interface**: Configurable retry parameters + - `maxRetries`: Maximum retry attempts (default: 3) + - `initialDelayMs`: Initial delay between retries (default: 1000ms) + - `maxDelayMs`: Maximum delay cap (default: 30000ms) + - `backoffMultiplier`: Exponential multiplier (default: 2) + - `enableJitter`: Enable randomization (default: true) + - `jitterFactor`: Jitter percentage (default: 0.1 = ±10%) + +- **`isRetryableError()` function**: Smart error detection + - Network errors (ECONNREFUSED, ETIMEDOUT, etc.) + - Rate limiting errors + - HTTP 5xx errors (502, 503, 504) + - Timeout errors + - Temporary unavailability + +- **`calculateDelay()` function**: Exponential backoff calculation + - Formula: `min(initialDelay * (multiplier ^ attempt), maxDelay)` + - Optional jitter: `delay ± (delay * jitterFactor * random())` + +- **`withRetry()` function**: Standalone retry wrapper + - Executes function with retry logic + - Logs retry attempts with context + - Throws non-retryable errors immediately + +- **`RetryPolicy` class**: Reusable retry policy + - Encapsulates retry configuration + - `execute()`: Run function with retries + - `updateConfig()`: Dynamically update settings + - `getConfig()`: Get current configuration + +### 2. SDK Integration (`src/core.ts`) +Updated `SorobanSdkCore` class: + +- Added `retryPolicy` property initialized from config +- Wrapped all RPC calls with retry logic: + - `simulateTransaction()` - for read operations + - `sendTransaction()` - for write operations + - `getTransaction()` - for transaction status polling + +### 3. Type Definitions (`src/types.ts`) +Extended `TokenboundSdkConfig`: +- Added optional `retryConfig?: RetryConfig` parameter +- Allows users to customize retry behavior per SDK instance + +### 4. Exports (`src/index.ts`) +Added retry module to public API: +- Export all retry utilities +- Users can use `RetryPolicy` and `withRetry` directly + +### 5. Comprehensive Tests (`src/__tests__/retry.test.ts`) +Created full test suite covering: + +- **Error Classification** + - Retryable error detection + - Non-retryable error handling + - Various error types and patterns + +- **Delay Calculation** + - Exponential backoff verification + - Max delay capping + - Jitter application + - Custom multipliers and initial delays + +- **Retry Logic** + - Success on first attempt + - Retry on transient errors + - No retry on permanent errors + - Max retries exhaustion + - Retry logging + +- **RetryPolicy Class** + - Default configuration + - Custom configuration + - Config updates + - Config retrieval + +### 6. Documentation + +#### `RETRY_POLICY.md` +Comprehensive documentation including: +- Feature overview +- Configuration options +- Retryable error patterns +- Delay calculation formulas +- Usage examples +- Best practices +- Performance considerations +- Troubleshooting guide +- Migration guide + +#### `README.md` Updates +- Added retry policy to feature list +- Included configuration example +- Added link to detailed documentation + +#### `examples/retry-usage.ts` +8 practical examples demonstrating: +1. Basic usage with defaults +2. Custom retry configuration +3. Direct RetryPolicy usage +4. One-off retries with withRetry +5. Scenario-based configurations +6. Dynamic config updates +7. Error handling patterns +8. Monitoring retry behavior + +## Key Features + +### 1. Automatic Retry +All RPC operations automatically retry on transient failures without code changes. + +### 2. Smart Error Detection +Only retries appropriate errors: +- ✅ Network failures +- ✅ Timeouts +- ✅ Rate limits +- ✅ 5xx errors +- ❌ Invalid arguments +- ❌ Authentication errors +- ❌ Not found errors + +### 3. Exponential Backoff +Delays increase exponentially to avoid overwhelming servers: +- Attempt 1: 1s +- Attempt 2: 2s +- Attempt 3: 4s +- Attempt 4: 8s (capped at maxDelay) + +### 4. Jitter +Randomizes delays to prevent thundering herd: +- Prevents synchronized retries +- Reduces server load spikes +- Improves overall system stability + +### 5. Configurable +Fully customizable per SDK instance: +```typescript +const sdk = createTokenboundSdk({ + // ... other config + retryConfig: { + maxRetries: 5, + initialDelayMs: 2000, + maxDelayMs: 60000, + backoffMultiplier: 2, + enableJitter: true, + jitterFactor: 0.15, + }, +}); +``` + +### 6. Observable +Logs retry attempts for monitoring: +``` +RPC call failed (simulate eventManager.createEvent), retrying in 1023ms (attempt 1/3)... Network error +``` + +## Benefits + +### For Users +- **Improved Reliability**: Automatic recovery from transient failures +- **Better UX**: Fewer failed operations due to temporary issues +- **Transparent**: Works automatically without code changes + +### For Developers +- **Easy to Use**: Works out of the box with sensible defaults +- **Flexible**: Fully configurable for different scenarios +- **Testable**: Comprehensive test coverage +- **Observable**: Clear logging for debugging + +### For Operations +- **Reduced Load**: Exponential backoff prevents server overload +- **Better Resilience**: Handles rate limits and temporary outages +- **Monitoring**: Retry logs help identify issues + +## Testing + +Run the test suite: +```bash +cd soroban-client +npm test -- retry.test.ts +``` + +Test coverage includes: +- ✅ Error classification (10+ test cases) +- ✅ Delay calculation (6+ test cases) +- ✅ Retry logic (8+ test cases) +- ✅ RetryPolicy class (6+ test cases) + +## Migration + +### Existing Code +No changes required! The retry policy is automatically applied to all RPC operations. + +### Custom Retry Logic +If you have custom retry logic, you can remove it: + +**Before:** +```typescript +async function callWithRetry() { + for (let i = 0; i < 3; i++) { + try { + return await sdk.eventManager.getEvent({ eventId: 1 }); + } catch (error) { + if (i === 2) throw error; + await sleep(1000 * Math.pow(2, i)); + } + } +} +``` + +**After:** +```typescript +const event = await sdk.eventManager.getEvent({ eventId: 1 }); +``` + +## Performance Impact + +### Best Case (No Retries) +- Zero overhead +- Immediate response + +### Worst Case (Max Retries) +- Default config: ~7 seconds total delay (1s + 2s + 4s) +- Custom config: Depends on configuration + +### Network Overhead +- Minimal: Only retries on actual failures +- Smart: Only retries appropriate errors + +## Future Enhancements + +Potential improvements: +- Circuit breaker pattern +- Adaptive retry strategies +- Per-operation retry configs +- Retry metrics and analytics +- Custom error classifiers +- Retry budget management + +## Files Changed + +### New Files +- `soroban-client/sdk/src/retry.ts` (180 lines) +- `soroban-client/sdk/src/__tests__/retry.test.ts` (280 lines) +- `soroban-client/sdk/RETRY_POLICY.md` (450 lines) +- `soroban-client/sdk/examples/retry-usage.ts` (280 lines) + +### Modified Files +- `soroban-client/sdk/src/core.ts` (added retry integration) +- `soroban-client/sdk/src/types.ts` (added RetryConfig) +- `soroban-client/sdk/src/index.ts` (added exports) +- `soroban-client/sdk/README.md` (added documentation) + +### Total Changes +- **8 files changed** +- **1,134 insertions** +- **4 deletions** + +## Commit +``` +feat: Add exponential backoff and retry policy for RPC calls + +- Implement RetryPolicy class with configurable retry parameters +- Add exponential backoff with jitter to prevent thundering herd +- Automatically retry transient RPC failures (network errors, timeouts, 5xx) +- Integrate retry logic into all RPC operations (simulate, send, getTransaction) +- Add comprehensive test suite for retry functionality +- Include detailed documentation and usage examples + +Resolves #220 +``` + +## Branch +- **Name**: `feature/soroban-rpc-retry-policy` +- **Status**: Pushed to remote +- **Ready for**: Pull request and review + +## Next Steps + +1. ✅ Create pull request +2. ⏳ Code review +3. ⏳ Run CI/CD tests +4. ⏳ Merge to main +5. ⏳ Deploy to production + +## References + +- [Issue #220](https://github.com/crowdpass-live/tokenbound_impl/issues/220) +- [Exponential Backoff](https://en.wikipedia.org/wiki/Exponential_backoff) +- [Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) +- [Stellar SDK Documentation](https://stellar.github.io/js-stellar-sdk/) diff --git a/soroban-client/sdk/README.md b/soroban-client/sdk/README.md index 5ec38888..81f301c8 100644 --- a/soroban-client/sdk/README.md +++ b/soroban-client/sdk/README.md @@ -8,6 +8,7 @@ Typed internal SDK for the CrowdPass Soroban contracts. - Typed wrappers for Event Manager, Ticket Factory, Ticket NFT, TBA Registry, and TBA Account - Shared transaction builders for read, simulate, sign, and submit flows - Contract error decoding into SDK-friendly error objects +- **Typed decoder utilities for safe contract response parsing** ## Usage @@ -54,3 +55,33 @@ const result = await sdk.eventManager.createEvent( cd soroban-client npm run sdk:generate-types ``` + +### Typed Decoders + +The SDK provides typed decoder utilities for safely parsing contract responses: + +```ts +import { ContractDecoder, decodeArray, decodeStruct, decodeU32, decodeString, decodeI128 } from "./src"; + +// Decode event response +const event = ContractDecoder.event()(rawResponse); + +// Decode array of tiers +const tiers = decodeArray(ContractDecoder.ticketTier())(rawTiers); + +// Build custom decoders +const decodeCustom = decodeStruct({ + id: decodeU32, + name: decodeString, + price: decodeI128, +}); +``` + +**Key Features:** +- Type-safe contract response parsing +- Composable decoders for complex structures +- Clear error messages with context +- Built-in Soroban type support (u32, u64, u128, i128, etc.) +- Pre-built decoders for contract types + +See [DECODERS.md](./DECODERS.md) for detailed documentation. diff --git a/soroban-client/sdk/examples/decoder-usage.ts b/soroban-client/sdk/examples/decoder-usage.ts new file mode 100644 index 00000000..6ceccbfe --- /dev/null +++ b/soroban-client/sdk/examples/decoder-usage.ts @@ -0,0 +1,542 @@ +/** + * Examples of using typed decoder utilities for Soroban contract responses + */ + +import { + ContractDecoder, + decodeAddress, + decodeArray, + decodeBigInt, + decodeBoolean, + decodeEnum, + decodeI128, + decodeNumber, + decodeOneOf, + decodeOption, + decodeString, + decodeStruct, + decodeTransform, + decodeTuple, + decodeU128, + decodeU32, + decodeU64, + decodeValidate, + decodeWithDefault, + safeDecode, +} from "../src"; + +// ============================================================================ +// Example 1: Basic Primitive Decoding +// ============================================================================ + +function example1_primitives() { + console.log("=== Example 1: Primitive Decoding ==="); + + // Decode string + const name = decodeString("Alice"); + console.log("Name:", name); + + // Decode number + const age = decodeNumber(25); + console.log("Age:", age); + + // Decode bigint + const balance = decodeBigInt("1000000000"); + console.log("Balance:", balance); + + // Decode boolean + const active = decodeBoolean(true); + console.log("Active:", active); +} + +// ============================================================================ +// Example 2: Soroban-Specific Types +// ============================================================================ + +function example2_sorobanTypes() { + console.log("\n=== Example 2: Soroban Types ==="); + + // u32: event ID + const eventId = decodeU32(123); + console.log("Event ID:", eventId); + + // u64: timestamp + const timestamp = decodeU64(1234567890); + console.log("Timestamp:", timestamp); + + // u128: total tickets + const totalTickets = decodeU128(1000n); + console.log("Total Tickets:", totalTickets); + + // i128: ticket price + const price = decodeI128(5000000000n); + console.log("Price:", price); + + // Address + const organizer = decodeAddress("GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + console.log("Organizer:", organizer); +} + +// ============================================================================ +// Example 3: Arrays and Vectors +// ============================================================================ + +function example3_arrays() { + console.log("\n=== Example 3: Arrays ==="); + + // Array of numbers + const eventIds = decodeArray(decodeU32)([1, 2, 3, 4, 5]); + console.log("Event IDs:", eventIds); + + // Array of addresses + const attendees = decodeArray(decodeAddress)([ + "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + ]); + console.log("Attendees:", attendees.length); + + // Nested arrays + const matrix = decodeArray(decodeArray(decodeNumber))([ + [1, 2, 3], + [4, 5, 6], + ]); + console.log("Matrix:", matrix); +} + +// ============================================================================ +// Example 4: Optional Values +// ============================================================================ + +function example4_optionals() { + console.log("\n=== Example 4: Optional Values ==="); + + const decodeOptionalPrice = decodeOption(decodeI128); + + // Null value + const noPrice = decodeOptionalPrice(null); + console.log("No price:", noPrice); + + // Some value (Soroban format) + const somePrice = decodeOptionalPrice({ Some: 1000000000n }); + console.log("Some price:", somePrice); + + // Direct value + const directPrice = decodeOptionalPrice(2000000000n); + console.log("Direct price:", directPrice); +} + +// ============================================================================ +// Example 5: Structs/Objects +// ============================================================================ + +function example5_structs() { + console.log("\n=== Example 5: Structs ==="); + + // Define user decoder + const decodeUser = decodeStruct({ + id: decodeU32, + name: decodeString, + email: decodeString, + balance: decodeU128, + active: decodeBoolean, + }); + + // Decode user + const user = decodeUser({ + id: 1, + name: "Alice", + email: "alice@example.com", + balance: 1000000000n, + active: true, + }); + + console.log("User:", user); +} + +// ============================================================================ +// Example 6: Tuples +// ============================================================================ + +function example6_tuples() { + console.log("\n=== Example 6: Tuples ==="); + + // TBA token tuple: (chain_id, token_contract, token_id) + const decodeToken = decodeTuple( + decodeU32, + decodeAddress, + decodeU128 + ); + + const token = decodeToken([ + 1, + "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + 123n, + ]); + + console.log("Token:", token); + console.log("Chain ID:", token[0]); + console.log("Contract:", token[1]); + console.log("Token ID:", token[2]); +} + +// ============================================================================ +// Example 7: Contract-Specific Decoders +// ============================================================================ + +function example7_contractDecoders() { + console.log("\n=== Example 7: Contract Decoders ==="); + + // Decode Event + const event = ContractDecoder.event()({ + id: 1, + theme: "Web3 Conference 2024", + organizer: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + event_type: "Conference", + total_tickets: 500n, + tickets_sold: 250n, + ticket_price: 1000000000n, + start_date: 1234567890, + end_date: 1234567900, + is_canceled: false, + ticket_nft_addr: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + payment_token: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }); + + console.log("Event:", event.theme); + console.log("Tickets sold:", event.tickets_sold, "/", event.total_tickets); + + // Decode Ticket Tier + const tier = ContractDecoder.ticketTier()({ + name: "VIP", + price: 5000000000n, + total_quantity: 50n, + sold_quantity: 25n, + }); + + console.log("Tier:", tier.name, "-", tier.price); + + // Decode Buyer Purchase + const purchase = ContractDecoder.buyerPurchase()({ + quantity: 2n, + total_paid: 2000000000n, + }); + + console.log("Purchase:", purchase.quantity, "tickets for", purchase.total_paid); +} + +// ============================================================================ +// Example 8: Complex Nested Structures +// ============================================================================ + +function example8_nested() { + console.log("\n=== Example 8: Nested Structures ==="); + + // Event with tiers + const decodeEventWithTiers = decodeStruct({ + id: decodeU32, + name: decodeString, + tiers: decodeArray( + decodeStruct({ + name: decodeString, + price: decodeI128, + available: decodeU32, + }) + ), + organizer: decodeAddress, + }); + + const event = decodeEventWithTiers({ + id: 1, + name: "Tech Conference", + tiers: [ + { name: "General", price: 1000000000n, available: 100 }, + { name: "VIP", price: 5000000000n, available: 20 }, + { name: "Student", price: 500000000n, available: 50 }, + ], + organizer: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }); + + console.log("Event:", event.name); + console.log("Tiers:", event.tiers.map((t) => t.name).join(", ")); +} + +// ============================================================================ +// Example 9: Transform Decoded Values +// ============================================================================ + +function example9_transform() { + console.log("\n=== Example 9: Transform Values ==="); + + // Decode and convert to uppercase + const decodeUppercase = decodeTransform( + decodeString, + (s) => s.toUpperCase() + ); + + const theme = decodeUppercase("web3 conference"); + console.log("Theme:", theme); + + // Decode price and convert to dollars + const decodePriceInDollars = decodeTransform( + decodeI128, + (stroops) => Number(stroops) / 10000000 // Convert stroops to XLM + ); + + const priceInDollars = decodePriceInDollars(50000000n); + console.log("Price:", priceInDollars, "XLM"); + + // Decode timestamp and convert to Date + const decodeDate = decodeTransform( + decodeU64, + (timestamp) => new Date(timestamp * 1000) + ); + + const eventDate = decodeDate(1234567890); + console.log("Event date:", eventDate.toISOString()); +} + +// ============================================================================ +// Example 10: Validate Decoded Values +// ============================================================================ + +function example10_validate() { + console.log("\n=== Example 10: Validate Values ==="); + + // Validate positive price + const decodePositivePrice = decodeValidate( + decodeI128, + (price) => price > 0n, + "Price must be positive" + ); + + try { + const validPrice = decodePositivePrice(1000000000n); + console.log("Valid price:", validPrice); + + const invalidPrice = decodePositivePrice(-1000n); + console.log("Invalid price:", invalidPrice); + } catch (error) { + console.log("Validation error:", error.message); + } + + // Validate email format + const decodeEmail = decodeValidate( + decodeString, + (email) => email.includes("@"), + "Invalid email format" + ); + + try { + const validEmail = decodeEmail("alice@example.com"); + console.log("Valid email:", validEmail); + } catch (error) { + console.log("Validation error:", error.message); + } +} + +// ============================================================================ +// Example 11: Decode with Default Values +// ============================================================================ + +function example11_defaults() { + console.log("\n=== Example 11: Default Values ==="); + + // Decode with default + const decodeQuantityWithDefault = decodeWithDefault(decodeU128, 1n); + + const quantity1 = decodeQuantityWithDefault(5n); + console.log("Quantity 1:", quantity1); + + const quantity2 = decodeQuantityWithDefault("invalid"); + console.log("Quantity 2 (default):", quantity2); + + // Decode optional with default + const decodeOptionalName = decodeWithDefault( + decodeOption(decodeString), + "Anonymous" + ); + + const name1 = decodeOptionalName("Alice"); + console.log("Name 1:", name1); + + const name2 = decodeOptionalName(null); + console.log("Name 2 (default):", name2); +} + +// ============================================================================ +// Example 12: Decode One of Multiple Types +// ============================================================================ + +function example12_oneOf() { + console.log("\n=== Example 12: One Of ==="); + + // Decode number or string + const decodeNumberOrString = decodeOneOf( + decodeNumber, + decodeString + ); + + const value1 = decodeNumberOrString(42); + console.log("Value 1:", value1, typeof value1); + + const value2 = decodeNumberOrString("hello"); + console.log("Value 2:", value2, typeof value2); + + // Decode bigint or number + const decodeBigIntOrNumber = decodeOneOf( + decodeBigInt, + decodeNumber + ); + + const amount1 = decodeBigIntOrNumber(1000000000n); + console.log("Amount 1:", amount1); + + const amount2 = decodeBigIntOrNumber(123); + console.log("Amount 2:", amount2); +} + +// ============================================================================ +// Example 13: Decode Enum Values +// ============================================================================ + +function example13_enums() { + console.log("\n=== Example 13: Enums ==="); + + // Event type enum + const decodeEventType = decodeEnum( + ["Conference", "Concert", "Workshop", "Meetup"] as const, + "EventType" + ); + + const type1 = decodeEventType("Conference"); + console.log("Type 1:", type1); + + try { + const type2 = decodeEventType("Invalid"); + console.log("Type 2:", type2); + } catch (error) { + console.log("Enum error:", error.message); + } + + // Status enum + const decodeStatus = decodeEnum( + ["pending", "active", "completed", "canceled"] as const, + "Status" + ); + + const status = decodeStatus("active"); + console.log("Status:", status); +} + +// ============================================================================ +// Example 14: Safe Decoding +// ============================================================================ + +function example14_safeDecoding() { + console.log("\n=== Example 14: Safe Decoding ==="); + + // Safe decode returns result object + const result1 = safeDecode(decodeNumber, 42); + if (result1.success) { + console.log("Success:", result1.value); + } + + const result2 = safeDecode(decodeNumber, "invalid"); + if (!result2.success) { + console.log("Error:", result2.error.message); + } + + // Use in array processing + const values = [1, "invalid", 3, "bad", 5]; + const decoded = values + .map((v) => safeDecode(decodeNumber, v)) + .filter((r) => r.success) + .map((r) => r.success && r.value); + + console.log("Decoded values:", decoded); +} + +// ============================================================================ +// Example 15: Real-World Usage with SDK +// ============================================================================ + +async function example15_realWorld() { + console.log("\n=== Example 15: Real-World Usage ==="); + + // Simulated contract response + const mockEventResponse = { + id: 1, + theme: "Stellar Builders Summit 2024", + organizer: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + event_type: "Conference", + total_tickets: 500n, + tickets_sold: 350n, + ticket_price: 2500000000n, + start_date: 1735689600, + end_date: 1735776000, + is_canceled: false, + ticket_nft_addr: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + payment_token: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }; + + // Decode event + const event = ContractDecoder.event()(mockEventResponse); + + console.log("Event Details:"); + console.log(" Theme:", event.theme); + console.log(" Organizer:", event.organizer.substring(0, 10) + "..."); + console.log(" Type:", event.event_type); + console.log(" Tickets:", event.tickets_sold, "/", event.total_tickets); + console.log(" Price:", event.ticket_price, "stroops"); + console.log(" Canceled:", event.is_canceled); + + // Calculate availability + const available = event.total_tickets - event.tickets_sold; + const percentSold = Number((event.tickets_sold * 100n) / event.total_tickets); + + console.log(" Available:", available); + console.log(" Sold:", percentSold.toFixed(1) + "%"); +} + +// ============================================================================ +// Run All Examples +// ============================================================================ + +function runAllExamples() { + example1_primitives(); + example2_sorobanTypes(); + example3_arrays(); + example4_optionals(); + example5_structs(); + example6_tuples(); + example7_contractDecoders(); + example8_nested(); + example9_transform(); + example10_validate(); + example11_defaults(); + example12_oneOf(); + example13_enums(); + example14_safeDecoding(); + example15_realWorld().catch(console.error); +} + +// Uncomment to run examples +// runAllExamples(); + +export { + example1_primitives, + example2_sorobanTypes, + example3_arrays, + example4_optionals, + example5_structs, + example6_tuples, + example7_contractDecoders, + example8_nested, + example9_transform, + example10_validate, + example11_defaults, + example12_oneOf, + example13_enums, + example14_safeDecoding, + example15_realWorld, +}; diff --git a/soroban-client/sdk/src/__tests__/decoders.test.ts b/soroban-client/sdk/src/__tests__/decoders.test.ts new file mode 100644 index 00000000..d972bc3a --- /dev/null +++ b/soroban-client/sdk/src/__tests__/decoders.test.ts @@ -0,0 +1,548 @@ +import { + ContractDecoder, + decodeAddress, + decodeArray, + decodeBigInt, + decodeBoolean, + decodeBytes, + decodeBytesN, + DecoderError, + decodeEnum, + decodeI128, + decodeI32, + decodeI64, + decodeLiteral, + decodeMap, + decodeNumber, + decodeOneOf, + decodeOption, + decodeString, + decodeStruct, + decodeSymbol, + decodeTransform, + decodeTuple, + decodeU128, + decodeU32, + decodeU64, + decodeValidate, + decodeVec, + decodeVoid, + decodeWithDefault, + safeDecode, +} from "../decoders"; + +describe("Decoders", () => { + describe("Primitive Decoders", () => { + describe("decodeString", () => { + it("should decode string values", () => { + expect(decodeString("hello")).toBe("hello"); + expect(decodeString("")).toBe(""); + }); + + it("should throw on non-string values", () => { + expect(() => decodeString(123)).toThrow(DecoderError); + expect(() => decodeString(null)).toThrow(DecoderError); + expect(() => decodeString(undefined)).toThrow(DecoderError); + }); + }); + + describe("decodeNumber", () => { + it("should decode number values", () => { + expect(decodeNumber(123)).toBe(123); + expect(decodeNumber(0)).toBe(0); + expect(decodeNumber(-456)).toBe(-456); + expect(decodeNumber(3.14)).toBe(3.14); + }); + + it("should decode string numbers", () => { + expect(decodeNumber("123")).toBe(123); + expect(decodeNumber("3.14")).toBe(3.14); + }); + + it("should decode bigint to number", () => { + expect(decodeNumber(123n)).toBe(123); + }); + + it("should throw on invalid values", () => { + expect(() => decodeNumber("not a number")).toThrow(DecoderError); + expect(() => decodeNumber(null)).toThrow(DecoderError); + }); + }); + + describe("decodeBigInt", () => { + it("should decode bigint values", () => { + expect(decodeBigInt(123n)).toBe(123n); + expect(decodeBigInt(0n)).toBe(0n); + }); + + it("should decode number to bigint", () => { + expect(decodeBigInt(123)).toBe(123n); + }); + + it("should decode string to bigint", () => { + expect(decodeBigInt("123")).toBe(123n); + expect(decodeBigInt("999999999999999999")).toBe(999999999999999999n); + }); + + it("should throw on invalid values", () => { + expect(() => decodeBigInt("not a number")).toThrow(DecoderError); + expect(() => decodeBigInt(null)).toThrow(DecoderError); + }); + }); + + describe("decodeBoolean", () => { + it("should decode boolean values", () => { + expect(decodeBoolean(true)).toBe(true); + expect(decodeBoolean(false)).toBe(false); + }); + + it("should throw on non-boolean values", () => { + expect(() => decodeBoolean(1)).toThrow(DecoderError); + expect(() => decodeBoolean("true")).toThrow(DecoderError); + expect(() => decodeBoolean(null)).toThrow(DecoderError); + }); + }); + + describe("decodeBytes", () => { + it("should decode Uint8Array", () => { + const bytes = new Uint8Array([1, 2, 3]); + expect(decodeBytes(bytes)).toEqual(bytes); + }); + + it("should decode hex string", () => { + const result = decodeBytes("0x010203"); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("should decode hex string without 0x prefix", () => { + const result = decodeBytes("010203"); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("should throw on invalid values", () => { + expect(() => decodeBytes("not hex")).toThrow(DecoderError); + expect(() => decodeBytes(123)).toThrow(DecoderError); + }); + }); + + describe("decodeAddress", () => { + it("should decode valid Stellar addresses", () => { + const address = "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + expect(decodeAddress(address)).toBe(address); + + const contractAddress = "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + expect(decodeAddress(contractAddress)).toBe(contractAddress); + }); + + it("should throw on invalid addresses", () => { + expect(() => decodeAddress("invalid")).toThrow(DecoderError); + expect(() => decodeAddress("GXXX")).toThrow(DecoderError); + expect(() => decodeAddress(123)).toThrow(DecoderError); + }); + }); + + describe("decodeSymbol", () => { + it("should decode symbol as string", () => { + expect(decodeSymbol("transfer")).toBe("transfer"); + expect(decodeSymbol("mint")).toBe("mint"); + }); + }); + }); + + describe("Composite Decoders", () => { + describe("decodeArray", () => { + it("should decode array of numbers", () => { + const decoder = decodeArray(decodeNumber); + expect(decoder([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it("should decode array of strings", () => { + const decoder = decodeArray(decodeString); + expect(decoder(["a", "b", "c"])).toEqual(["a", "b", "c"]); + }); + + it("should decode empty array", () => { + const decoder = decodeArray(decodeNumber); + expect(decoder([])).toEqual([]); + }); + + it("should throw on non-array", () => { + const decoder = decodeArray(decodeNumber); + expect(() => decoder("not array")).toThrow(DecoderError); + }); + + it("should throw on invalid element", () => { + const decoder = decodeArray(decodeNumber); + expect(() => decoder([1, "invalid", 3])).toThrow(DecoderError); + }); + }); + + describe("decodeVec", () => { + it("should work like decodeArray", () => { + const decoder = decodeVec(decodeNumber); + expect(decoder([1, 2, 3])).toEqual([1, 2, 3]); + }); + }); + + describe("decodeOption", () => { + it("should decode null as null", () => { + const decoder = decodeOption(decodeNumber); + expect(decoder(null)).toBeNull(); + expect(decoder(undefined)).toBeNull(); + }); + + it("should decode Some wrapper", () => { + const decoder = decodeOption(decodeNumber); + expect(decoder({ Some: 123 })).toBe(123); + }); + + it("should decode direct value", () => { + const decoder = decodeOption(decodeNumber); + expect(decoder(123)).toBe(123); + }); + + it("should return null on decode failure", () => { + const decoder = decodeOption(decodeNumber); + expect(decoder("invalid")).toBeNull(); + }); + }); + + describe("decodeTuple", () => { + it("should decode tuple with mixed types", () => { + const decoder = decodeTuple(decodeNumber, decodeString, decodeBoolean); + expect(decoder([123, "hello", true])).toEqual([123, "hello", true]); + }); + + it("should throw on wrong length", () => { + const decoder = decodeTuple(decodeNumber, decodeString); + expect(() => decoder([123])).toThrow(DecoderError); + expect(() => decoder([123, "hello", "extra"])).toThrow(DecoderError); + }); + + it("should throw on non-array", () => { + const decoder = decodeTuple(decodeNumber); + expect(() => decoder("not array")).toThrow(DecoderError); + }); + }); + + describe("decodeStruct", () => { + it("should decode object with field decoders", () => { + const decoder = decodeStruct({ + id: decodeNumber, + name: decodeString, + active: decodeBoolean, + }); + + expect(decoder({ id: 1, name: "test", active: true })).toEqual({ + id: 1, + name: "test", + active: true, + }); + }); + + it("should throw on missing field", () => { + const decoder = decodeStruct({ + id: decodeNumber, + name: decodeString, + }); + + expect(() => decoder({ id: 1 })).toThrow(DecoderError); + }); + + it("should throw on non-object", () => { + const decoder = decodeStruct({ id: decodeNumber }); + expect(() => decoder("not object")).toThrow(DecoderError); + expect(() => decoder(null)).toThrow(DecoderError); + }); + }); + + describe("decodeMap", () => { + it("should decode map/record", () => { + const decoder = decodeMap(decodeString, decodeNumber); + expect(decoder({ a: 1, b: 2, c: 3 })).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("should throw on invalid value", () => { + const decoder = decodeMap(decodeString, decodeNumber); + expect(() => decoder({ a: "invalid" })).toThrow(DecoderError); + }); + }); + }); + + describe("Utility Decoders", () => { + describe("decodeWithDefault", () => { + it("should return decoded value on success", () => { + const decoder = decodeWithDefault(decodeNumber, 0); + expect(decoder(123)).toBe(123); + }); + + it("should return default on failure", () => { + const decoder = decodeWithDefault(decodeNumber, 0); + expect(decoder("invalid")).toBe(0); + }); + }); + + describe("decodeOneOf", () => { + it("should try decoders in order", () => { + const decoder = decodeOneOf(decodeNumber, decodeString); + expect(decoder(123)).toBe(123); + expect(decoder("hello")).toBe("hello"); + }); + + it("should throw if all decoders fail", () => { + const decoder = decodeOneOf(decodeNumber, decodeBoolean); + expect(() => decoder("invalid")).toThrow(DecoderError); + }); + }); + + describe("decodeTransform", () => { + it("should decode and transform value", () => { + const decoder = decodeTransform(decodeNumber, (n) => n * 2); + expect(decoder(5)).toBe(10); + }); + + it("should transform string to uppercase", () => { + const decoder = decodeTransform(decodeString, (s) => s.toUpperCase()); + expect(decoder("hello")).toBe("HELLO"); + }); + }); + + describe("decodeValidate", () => { + it("should decode and validate value", () => { + const decoder = decodeValidate( + decodeNumber, + (n) => n > 0, + "Must be positive" + ); + expect(decoder(5)).toBe(5); + }); + + it("should throw on validation failure", () => { + const decoder = decodeValidate( + decodeNumber, + (n) => n > 0, + "Must be positive" + ); + expect(() => decoder(-5)).toThrow(DecoderError); + }); + }); + + describe("decodeLiteral", () => { + it("should decode exact literal value", () => { + const decoder = decodeLiteral("success"); + expect(decoder("success")).toBe("success"); + }); + + it("should throw on different value", () => { + const decoder = decodeLiteral("success"); + expect(() => decoder("failure")).toThrow(DecoderError); + }); + + it("should work with numbers", () => { + const decoder = decodeLiteral(42); + expect(decoder(42)).toBe(42); + expect(() => decoder(43)).toThrow(DecoderError); + }); + }); + + describe("decodeEnum", () => { + it("should decode valid enum value", () => { + const decoder = decodeEnum(["red", "green", "blue"] as const); + expect(decoder("red")).toBe("red"); + expect(decoder("green")).toBe("green"); + }); + + it("should throw on invalid enum value", () => { + const decoder = decodeEnum(["red", "green", "blue"] as const); + expect(() => decoder("yellow")).toThrow(DecoderError); + }); + }); + }); + + describe("Soroban-Specific Decoders", () => { + describe("decodeU32", () => { + it("should decode valid u32 values", () => { + expect(decodeU32(0)).toBe(0); + expect(decodeU32(4294967295)).toBe(4294967295); + }); + + it("should throw on negative values", () => { + expect(() => decodeU32(-1)).toThrow(DecoderError); + }); + + it("should throw on values > u32 max", () => { + expect(() => decodeU32(4294967296)).toThrow(DecoderError); + }); + + it("should throw on non-integer", () => { + expect(() => decodeU32(3.14)).toThrow(DecoderError); + }); + }); + + describe("decodeU64", () => { + it("should decode valid u64 values", () => { + expect(decodeU64(0)).toBe(0); + expect(decodeU64(123456789)).toBe(123456789); + }); + + it("should throw on negative values", () => { + expect(() => decodeU64(-1)).toThrow(DecoderError); + }); + }); + + describe("decodeU128", () => { + it("should decode valid u128 values", () => { + expect(decodeU128(0n)).toBe(0n); + expect(decodeU128(123456789n)).toBe(123456789n); + }); + + it("should throw on negative values", () => { + expect(() => decodeU128(-1n)).toThrow(DecoderError); + }); + }); + + describe("decodeI32", () => { + it("should decode valid i32 values", () => { + expect(decodeI32(-2147483648)).toBe(-2147483648); + expect(decodeI32(2147483647)).toBe(2147483647); + expect(decodeI32(0)).toBe(0); + }); + + it("should throw on out of range values", () => { + expect(() => decodeI32(-2147483649)).toThrow(DecoderError); + expect(() => decodeI32(2147483648)).toThrow(DecoderError); + }); + }); + + describe("decodeI64", () => { + it("should decode valid i64 values", () => { + expect(decodeI64(-123)).toBe(-123); + expect(decodeI64(123)).toBe(123); + }); + }); + + describe("decodeI128", () => { + it("should decode valid i128 values", () => { + expect(decodeI128(-123n)).toBe(-123n); + expect(decodeI128(123n)).toBe(123n); + }); + }); + + describe("decodeBytesN", () => { + it("should decode fixed-size bytes", () => { + const decoder = decodeBytesN(3); + const bytes = new Uint8Array([1, 2, 3]); + expect(decoder(bytes)).toEqual(bytes); + }); + + it("should throw on wrong size", () => { + const decoder = decodeBytesN(3); + const bytes = new Uint8Array([1, 2]); + expect(() => decoder(bytes)).toThrow(DecoderError); + }); + }); + + describe("decodeVoid", () => { + it("should decode void/unit", () => { + expect(decodeVoid(undefined)).toBeUndefined(); + expect(decodeVoid(null)).toBeUndefined(); + }); + + it("should throw on non-void values", () => { + expect(() => decodeVoid(123)).toThrow(DecoderError); + expect(() => decodeVoid("hello")).toThrow(DecoderError); + }); + }); + }); + + describe("ContractDecoder", () => { + describe("event", () => { + it("should decode Event struct", () => { + const decoder = ContractDecoder.event(); + const event = { + id: 1, + theme: "Web3 Conference", + organizer: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + event_type: "Conference", + total_tickets: 100n, + tickets_sold: 50n, + ticket_price: 1000000000n, + start_date: 1234567890, + end_date: 1234567900, + is_canceled: false, + ticket_nft_addr: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + payment_token: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }; + + expect(decoder(event)).toEqual(event); + }); + }); + + describe("ticketTier", () => { + it("should decode TicketTier struct", () => { + const decoder = ContractDecoder.ticketTier(); + const tier = { + name: "VIP", + price: 5000000000n, + total_quantity: 50n, + sold_quantity: 25n, + }; + + expect(decoder(tier)).toEqual(tier); + }); + }); + + describe("buyerPurchase", () => { + it("should decode BuyerPurchase struct", () => { + const decoder = ContractDecoder.buyerPurchase(); + const purchase = { + quantity: 2n, + total_paid: 2000000000n, + }; + + expect(decoder(purchase)).toEqual(purchase); + }); + }); + + describe("tbaToken", () => { + it("should decode TBA token tuple", () => { + const decoder = ContractDecoder.tbaToken(); + const token = [ + 1, + "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + 123n, + ]; + + expect(decoder(token)).toEqual(token); + }); + }); + }); + + describe("safeDecode", () => { + it("should return success result on valid decode", () => { + const result = safeDecode(decodeNumber, 123); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toBe(123); + } + }); + + it("should return error result on invalid decode", () => { + const result = safeDecode(decodeNumber, "invalid"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(DecoderError); + } + }); + }); + + describe("DecoderError", () => { + it("should contain error details", () => { + const error = new DecoderError("Test error", 123, "string"); + expect(error.message).toBe("Test error"); + expect(error.value).toBe(123); + expect(error.expectedType).toBe("string"); + expect(error.name).toBe("DecoderError"); + }); + }); +}); diff --git a/soroban-client/sdk/src/decoders.ts b/soroban-client/sdk/src/decoders.ts new file mode 100644 index 00000000..6d5314ac --- /dev/null +++ b/soroban-client/sdk/src/decoders.ts @@ -0,0 +1,621 @@ +/** + * Typed decoder utilities for safely parsing Soroban contract return values + */ + +import { scValToNative, xdr } from "@stellar/stellar-sdk"; + +/** + * Decoder error thrown when decoding fails + */ +export class DecoderError extends Error { + constructor( + message: string, + public readonly value: unknown, + public readonly expectedType: string + ) { + super(message); + this.name = "DecoderError"; + } +} + +/** + * Decoder function type + */ +export type Decoder = (value: unknown) => T; + +/** + * Decoder result type for safe decoding + */ +export type DecoderResult = + | { success: true; value: T } + | { success: false; error: DecoderError }; + +/** + * Safe decoder wrapper that returns a result instead of throwing + */ +export function safeDecode(decoder: Decoder, value: unknown): DecoderResult { + try { + return { success: true, value: decoder(value) }; + } catch (error) { + if (error instanceof DecoderError) { + return { success: false, error }; + } + return { + success: false, + error: new DecoderError( + error instanceof Error ? error.message : String(error), + value, + "unknown" + ), + }; + } +} + +/** + * Decode ScVal to native JavaScript value + */ +export function decodeScVal(scVal: xdr.ScVal): T { + try { + return scValToNative(scVal) as T; + } catch (error) { + throw new DecoderError( + `Failed to decode ScVal: ${error instanceof Error ? error.message : String(error)}`, + scVal, + "ScVal" + ); + } +} + +// ============================================================================ +// Primitive Decoders +// ============================================================================ + +/** + * Decode to string + */ +export function decodeString(value: unknown): string { + if (typeof value === "string") { + return value; + } + throw new DecoderError(`Expected string, got ${typeof value}`, value, "string"); +} + +/** + * Decode to number + */ +export function decodeNumber(value: unknown): number { + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + const num = Number(value); + if (!Number.isNaN(num)) { + return num; + } + } + if (typeof value === "bigint") { + return Number(value); + } + throw new DecoderError(`Expected number, got ${typeof value}`, value, "number"); +} + +/** + * Decode to bigint + */ +export function decodeBigInt(value: unknown): bigint { + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number") { + return BigInt(value); + } + if (typeof value === "string") { + try { + return BigInt(value); + } catch { + throw new DecoderError(`Invalid bigint string: ${value}`, value, "bigint"); + } + } + throw new DecoderError(`Expected bigint, got ${typeof value}`, value, "bigint"); +} + +/** + * Decode to boolean + */ +export function decodeBoolean(value: unknown): boolean { + if (typeof value === "boolean") { + return value; + } + throw new DecoderError(`Expected boolean, got ${typeof value}`, value, "boolean"); +} + +/** + * Decode to Uint8Array (bytes) + */ +export function decodeBytes(value: unknown): Uint8Array { + if (value instanceof Uint8Array) { + return value; + } + if (typeof value === "string") { + // Try to decode hex string + const hex = value.replace(/^0x/i, ""); + if (/^[0-9a-f]*$/i.test(hex) && hex.length % 2 === 0) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); + } + return bytes; + } + } + throw new DecoderError(`Expected Uint8Array or hex string, got ${typeof value}`, value, "bytes"); +} + +/** + * Decode to address (Stellar address string) + */ +export function decodeAddress(value: unknown): string { + const str = decodeString(value); + // Basic validation for Stellar address format + if (str.length === 56 && (str.startsWith("G") || str.startsWith("C"))) { + return str; + } + throw new DecoderError(`Invalid Stellar address format: ${str}`, value, "address"); +} + +/** + * Decode to symbol + */ +export function decodeSymbol(value: unknown): string { + return decodeString(value); +} + +// ============================================================================ +// Composite Decoders +// ============================================================================ + +/** + * Decode to array with element decoder + */ +export function decodeArray(elementDecoder: Decoder): Decoder { + return (value: unknown): T[] => { + if (!Array.isArray(value)) { + throw new DecoderError(`Expected array, got ${typeof value}`, value, "array"); + } + return value.map((item, index) => { + try { + return elementDecoder(item); + } catch (error) { + if (error instanceof DecoderError) { + throw new DecoderError( + `Array element at index ${index}: ${error.message}`, + value, + `array<${error.expectedType}>` + ); + } + throw error; + } + }); + }; +} + +/** + * Decode to Vec (alias for array) + */ +export function decodeVec(elementDecoder: Decoder): Decoder { + return decodeArray(elementDecoder); +} + +/** + * Decode to optional value + */ +export function decodeOption(decoder: Decoder): Decoder { + return (value: unknown): T | null => { + if (value === null || value === undefined) { + return null; + } + // Handle Soroban Option format: { Some: value } or null + if (typeof value === "object" && value !== null && "Some" in value) { + return decoder((value as { Some: unknown }).Some); + } + // If it's not null and not an Option wrapper, try to decode it directly + try { + return decoder(value); + } catch { + return null; + } + }; +} + +/** + * Decode to tuple with specific decoders for each element + */ +export function decodeTuple( + ...decoders: { [K in keyof T]: Decoder } +): Decoder { + return (value: unknown): T => { + if (!Array.isArray(value)) { + throw new DecoderError(`Expected tuple (array), got ${typeof value}`, value, "tuple"); + } + if (value.length !== decoders.length) { + throw new DecoderError( + `Expected tuple of length ${decoders.length}, got ${value.length}`, + value, + `tuple[${decoders.length}]` + ); + } + return decoders.map((decoder, index) => { + try { + return decoder(value[index]); + } catch (error) { + if (error instanceof DecoderError) { + throw new DecoderError( + `Tuple element at index ${index}: ${error.message}`, + value, + `tuple[${index}]` + ); + } + throw error; + } + }) as T; + }; +} + +/** + * Decode to object/struct with field decoders + */ +export function decodeStruct>( + fieldDecoders: { [K in keyof T]: Decoder } +): Decoder { + return (value: unknown): T => { + if (typeof value !== "object" || value === null) { + throw new DecoderError(`Expected object, got ${typeof value}`, value, "struct"); + } + const obj = value as Record; + const result = {} as T; + for (const [key, decoder] of Object.entries(fieldDecoders)) { + try { + result[key as keyof T] = decoder(obj[key]); + } catch (error) { + if (error instanceof DecoderError) { + throw new DecoderError( + `Struct field '${key}': ${error.message}`, + value, + `struct.${key}` + ); + } + throw error; + } + } + return result; + }; +} + +/** + * Decode to map/record + */ +export function decodeMap( + keyDecoder: Decoder, + valueDecoder: Decoder +): Decoder> { + return (value: unknown): Record => { + if (typeof value !== "object" || value === null) { + throw new DecoderError(`Expected object/map, got ${typeof value}`, value, "map"); + } + const obj = value as Record; + const result = {} as Record; + for (const [key, val] of Object.entries(obj)) { + try { + const decodedKey = keyDecoder(key); + const decodedValue = valueDecoder(val); + result[decodedKey] = decodedValue; + } catch (error) { + if (error instanceof DecoderError) { + throw new DecoderError( + `Map entry '${key}': ${error.message}`, + value, + `map<${error.expectedType}>` + ); + } + throw error; + } + } + return result; + }; +} + +// ============================================================================ +// Utility Decoders +// ============================================================================ + +/** + * Decode with fallback value if decoding fails + */ +export function decodeWithDefault(decoder: Decoder, defaultValue: T): Decoder { + return (value: unknown): T => { + try { + return decoder(value); + } catch { + return defaultValue; + } + }; +} + +/** + * Decode one of multiple possible types + */ +export function decodeOneOf(...decoders: Decoder[]): Decoder { + return (value: unknown): T => { + const errors: DecoderError[] = []; + for (const decoder of decoders) { + try { + return decoder(value); + } catch (error) { + if (error instanceof DecoderError) { + errors.push(error); + } + } + } + throw new DecoderError( + `Failed to decode with any of ${decoders.length} decoders: ${errors.map((e) => e.message).join(", ")}`, + value, + "oneOf" + ); + }; +} + +/** + * Decode and transform value + */ +export function decodeTransform(decoder: Decoder, transform: (value: T) => U): Decoder { + return (value: unknown): U => { + const decoded = decoder(value); + return transform(decoded); + }; +} + +/** + * Decode and validate value + */ +export function decodeValidate( + decoder: Decoder, + validate: (value: T) => boolean, + errorMessage: string +): Decoder { + return (value: unknown): T => { + const decoded = decoder(value); + if (!validate(decoded)) { + throw new DecoderError(errorMessage, value, typeof decoded as string); + } + return decoded; + }; +} + +/** + * Decode literal value + */ +export function decodeLiteral(literal: T): Decoder { + return (value: unknown): T => { + if (value === literal) { + return literal; + } + throw new DecoderError( + `Expected literal ${JSON.stringify(literal)}, got ${JSON.stringify(value)}`, + value, + `literal<${typeof literal}>` + ); + }; +} + +/** + * Decode enum value + */ +export function decodeEnum( + enumValues: readonly T[], + enumName = "enum" +): Decoder { + return (value: unknown): T => { + const str = decodeString(value); + if (enumValues.includes(str as T)) { + return str as T; + } + throw new DecoderError( + `Expected one of [${enumValues.join(", ")}], got ${str}`, + value, + enumName + ); + }; +} + +// ============================================================================ +// Soroban-Specific Decoders +// ============================================================================ + +/** + * Decode u32 (unsigned 32-bit integer) + */ +export function decodeU32(value: unknown): number { + const num = decodeNumber(value); + if (num < 0 || num > 4294967295 || !Number.isInteger(num)) { + throw new DecoderError(`Expected u32 (0-4294967295), got ${num}`, value, "u32"); + } + return num; +} + +/** + * Decode u64 (unsigned 64-bit integer) + */ +export function decodeU64(value: unknown): number { + const num = decodeNumber(value); + if (num < 0 || !Number.isInteger(num)) { + throw new DecoderError(`Expected u64 (non-negative integer), got ${num}`, value, "u64"); + } + return num; +} + +/** + * Decode u128 (unsigned 128-bit integer as bigint) + */ +export function decodeU128(value: unknown): bigint { + const bigint = decodeBigInt(value); + if (bigint < 0n) { + throw new DecoderError(`Expected u128 (non-negative), got ${bigint}`, value, "u128"); + } + return bigint; +} + +/** + * Decode i32 (signed 32-bit integer) + */ +export function decodeI32(value: unknown): number { + const num = decodeNumber(value); + if (num < -2147483648 || num > 2147483647 || !Number.isInteger(num)) { + throw new DecoderError(`Expected i32 (-2147483648 to 2147483647), got ${num}`, value, "i32"); + } + return num; +} + +/** + * Decode i64 (signed 64-bit integer) + */ +export function decodeI64(value: unknown): number { + const num = decodeNumber(value); + if (!Number.isInteger(num)) { + throw new DecoderError(`Expected i64 (integer), got ${num}`, value, "i64"); + } + return num; +} + +/** + * Decode i128 (signed 128-bit integer as bigint) + */ +export function decodeI128(value: unknown): bigint { + return decodeBigInt(value); +} + +/** + * Decode BytesN (fixed-size bytes) + */ +export function decodeBytesN(size: number): Decoder { + return (value: unknown): Uint8Array => { + const bytes = decodeBytes(value); + if (bytes.length !== size) { + throw new DecoderError( + `Expected BytesN<${size}>, got ${bytes.length} bytes`, + value, + `BytesN<${size}>` + ); + } + return bytes; + }; +} + +/** + * Decode void/unit type + */ +export function decodeVoid(value: unknown): void { + if (value === undefined || value === null) { + return undefined; + } + throw new DecoderError(`Expected void/unit, got ${typeof value}`, value, "void"); +} + +// ============================================================================ +// Contract-Specific Decoders +// ============================================================================ + +/** + * Decoder builder for contract return types + */ +export class ContractDecoder { + /** + * Create a decoder for Event struct + */ + static event() { + return decodeStruct({ + id: decodeU32, + theme: decodeString, + organizer: decodeAddress, + event_type: decodeString, + total_tickets: decodeU128, + tickets_sold: decodeU128, + ticket_price: decodeI128, + start_date: decodeU64, + end_date: decodeU64, + is_canceled: decodeBoolean, + ticket_nft_addr: decodeAddress, + payment_token: decodeAddress, + }); + } + + /** + * Create a decoder for TicketTier struct + */ + static ticketTier() { + return decodeStruct({ + name: decodeString, + price: decodeI128, + total_quantity: decodeU128, + sold_quantity: decodeU128, + }); + } + + /** + * Create a decoder for BuyerPurchase struct + */ + static buyerPurchase() { + return decodeStruct({ + quantity: decodeU128, + total_paid: decodeI128, + }); + } + + /** + * Create a decoder for TBA token tuple + */ + static tbaToken() { + return decodeTuple(decodeU32, decodeAddress, decodeU128); + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Decode contract response with automatic error handling + */ +export function decodeContractResponse( + decoder: Decoder, + response: unknown, + context?: string +): T { + try { + return decoder(response); + } catch (error) { + if (error instanceof DecoderError) { + const contextMsg = context ? ` (${context})` : ""; + throw new DecoderError( + `Failed to decode contract response${contextMsg}: ${error.message}`, + response, + error.expectedType + ); + } + throw error; + } +} + +/** + * Create a custom decoder with error context + */ +export function withContext(decoder: Decoder, context: string): Decoder { + return (value: unknown): T => { + try { + return decoder(value); + } catch (error) { + if (error instanceof DecoderError) { + throw new DecoderError(`${context}: ${error.message}`, value, error.expectedType); + } + throw error; + } + }; +} diff --git a/soroban-client/sdk/src/index.ts b/soroban-client/sdk/src/index.ts index 25ff9d53..a12f47a5 100644 --- a/soroban-client/sdk/src/index.ts +++ b/soroban-client/sdk/src/index.ts @@ -11,6 +11,7 @@ import type { TokenboundSdkConfig } from "./types"; export * from "./contracts"; export * from "./core"; +export * from "./decoders"; export * from "./errors"; export * from "./generated/contracts"; export * from "./types";