Skip to content

Commit 0e83a77

Browse files
authored
Merge pull request #145 from NanaKhadija1980j/feature/106-scval-serialization
feat: add ScVal serialization example (#139)
2 parents f548035 + 002f71d commit 0e83a77

4 files changed

Lines changed: 307 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ The repository currently includes the following runnable examples:
110110
62. **`69-soroban-contract-storage`**: Retrieving and inspecting Soroban contract storage entries via `getLedgerEntries`, decoding keys and values, and explaining instance, persistent, and temporary storage durability.
111111
63. **`70-soroban-authorization`**: Invoking an authorized Soroban contract method, obtaining and signing authorization entries from simulation, and explaining how authorization differs from transaction signatures.
112112
64. **`71-soroban-storage-update`**: Demonstrating the complete lifecycle of a Soroban storage update — reading initial state, simulating and submitting the modifying transaction, polling for confirmation, and verifying the updated value.
113+
65. **`106-scval-serialization`**: Converting JavaScript values to Soroban ScVal objects and back with reusable helpers, displaying raw XDR, and explaining common serialization pitfalls.
113114
65. **`105-contract-event-decoding`**: Retrieving Soroban contract events and decoding indexed topics and data payloads into human-readable values, with raw base64 XDR shown alongside decoded output.
114115
65. **`107-contract-spec-introspection`**: Retrieving on-chain WASM, parsing Soroban ScSpec metadata, and displaying functions, arguments, return types, user-defined types, and documentation with dynamic function selection.
115116
65. **`81-transaction-preflight`**: Running the full Soroban preflight workflow — simulating an invocation, extracting the footprint/authorization/resource-fee data, assembling, signing, submitting, and confirming the final transaction.
@@ -434,6 +435,13 @@ CONTRACT_ID=<id> CONTRACT_METHOD=increment CONTRACT_READ_METHOD=get npm run run-
434435

435436
The example reads the initial storage value, simulates and submits a state-modifying transaction, polls for on-chain confirmation, and re-reads the storage to display a before-and-after comparison.
436437

438+
Convert JavaScript values to Soroban ScVal and back:
439+
440+
```bash
441+
npm run run-example 106-scval-serialization
442+
```
443+
444+
This offline example encodes booleans, integers, BigInts, strings, symbols, bytes, addresses, vectors, maps, and nested objects using `src/utils/scval-utils.ts`, prints raw base64 XDR for each value, compares originals with decoded round-trip results, and demonstrates graceful handling of unsupported JavaScript types.
437445
Decode Soroban contract event topics and payloads:
438446

439447
```bash
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Keypair } from '@stellar/stellar-sdk';
2+
import chalk from 'chalk';
3+
4+
import {
5+
describeUnsupportedJsType,
6+
encodeNested,
7+
roundTrip,
8+
trySerialize,
9+
} from '../utils/scval-utils';
10+
11+
/**
12+
* Example 106: ScVal Serialization and Deserialization
13+
*
14+
* Soroban contracts exchange data as XDR-encoded ScVal values. This example
15+
* demonstrates converting common JavaScript values to ScVal and back using
16+
* reusable helpers, displaying raw base64 XDR, and comparing originals with
17+
* decoded round-trip results.
18+
*/
19+
20+
interface DemoCase {
21+
label: string;
22+
value: unknown;
23+
hint: { type: string; element?: { type: string }; key?: { type: string }; value?: { type: string } };
24+
}
25+
26+
const DEMO_CASES: DemoCase[] = [
27+
{ label: 'Boolean', value: true, hint: { type: 'bool' } },
28+
{ label: 'Integer (u32)', value: 42, hint: { type: 'u32' } },
29+
{ label: 'BigInt (i128)', value: 10_000_000_000n, hint: { type: 'i128' } },
30+
{ label: 'String', value: 'hello Soroban', hint: { type: 'string' } },
31+
{ label: 'Symbol', value: 'transfer', hint: { type: 'symbol' } },
32+
{ label: 'Bytes', value: Buffer.from('cafebabe', 'hex'), hint: { type: 'bytes' } },
33+
{
34+
label: 'Address',
35+
value: Keypair.random().publicKey(),
36+
hint: { type: 'address' },
37+
},
38+
{
39+
label: 'Vector<u32>',
40+
value: [1, 2, 3, 5, 8],
41+
hint: { type: 'vec', element: { type: 'u32' } },
42+
},
43+
{
44+
label: 'Map<symbol,u32>',
45+
value: [
46+
['alice', 100],
47+
['bob', 250],
48+
],
49+
hint: { type: 'map', key: { type: 'symbol' }, value: { type: 'u32' } },
50+
},
51+
];
52+
53+
function printRoundTrip(result: ReturnType<typeof roundTrip>): void {
54+
console.log(` original : ${JSON.stringify(result.original)}`);
55+
console.log(` xdr type : ${chalk.cyan(result.encoded.xdrType)}`);
56+
console.log(` raw XDR : ${result.encoded.rawXdr}`);
57+
console.log(` decoded : ${JSON.stringify(result.decoded)}`);
58+
console.log(
59+
result.matches
60+
? chalk.green(' match : yes')
61+
: chalk.yellow(' match : no (see serialization pitfalls below)'),
62+
);
63+
}
64+
65+
export function explainSerializationPitfalls(): string {
66+
return [
67+
'Serialization pitfalls:',
68+
' - JavaScript numbers above 2^53-1 must use BigInt for u64/i128/u256 types.',
69+
' - Symbol (scvSymbol) is not the same as String (scvString); contracts validate strictly.',
70+
' - Soroban maps decode to [key, value][] arrays, not plain objects, unless reshaped.',
71+
' - Option<T> uses scvVoid for None; undefined is not a valid ScVal input.',
72+
' - Passing the wrong hint throws before any RPC call — validate locally first.',
73+
].join('\n');
74+
}
75+
76+
export async function run(): Promise<void> {
77+
console.log(chalk.bold('ScVal Serialization and Deserialization Example'));
78+
console.log(chalk.gray('Offline round-trip encoding using reusable scval-utils helpers.\n'));
79+
80+
console.log(chalk.bold('Primitive and collection types'));
81+
for (const demo of DEMO_CASES) {
82+
console.log(chalk.yellow(`\n${demo.label}`));
83+
printRoundTrip(roundTrip(demo.value, demo.hint));
84+
}
85+
86+
console.log(chalk.bold('\nNested object (manual scvMap construction)'));
87+
const nested = {
88+
active: true,
89+
count: 3,
90+
label: 'nested',
91+
scores: [10, 20, 30],
92+
meta: { version: 2, owner: 'alice' },
93+
};
94+
const nestedScVal = encodeNested(nested);
95+
console.log(` original : ${JSON.stringify(nested)}`);
96+
console.log(` xdr type : ${chalk.cyan(nestedScVal.switch().name)}`);
97+
console.log(` raw XDR : ${nestedScVal.toXDR('base64')}`);
98+
99+
console.log(chalk.bold('\nUnsupported JavaScript types'));
100+
for (const bad of [undefined, () => 'noop', new Date()]) {
101+
const attempt = trySerialize(bad, { type: 'u32' });
102+
if (!attempt.ok) {
103+
console.log(chalk.red(` ✗ ${describeUnsupportedJsType(bad)}`));
104+
console.log(chalk.gray(` encoder error: ${attempt.error}`));
105+
}
106+
}
107+
108+
console.log('\n' + explainSerializationPitfalls());
109+
console.log(chalk.green('\nScVal serialization example completed.'));
110+
}

src/runner/catalog.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,11 @@ export const examples: Record<string, Example> = {
621621
'Read initial contract storage, invoke a state-modifying method, confirm the transaction, and verify the updated storage value',
622622
run: loadExample('../examples/71-soroban-storage-update'),
623623
},
624+
'106-scval-serialization': {
625+
name: '106-scval-serialization',
626+
description:
627+
'Convert JavaScript values to Soroban ScVal objects and back with reusable helpers',
628+
run: loadExample('../examples/106-scval-serialization'),
624629
'105-contract-event-decoding': {
625630
name: '105-contract-event-decoding',
626631
description:

src/utils/scval-utils.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {
2+
Address,
3+
nativeToScVal,
4+
scValToNative,
5+
StrKey,
6+
xdr,
7+
} from '@stellar/stellar-sdk';
8+
9+
/** Supported JavaScript input types for Soroban ScVal encoding. */
10+
export type ScValJsInput =
11+
| boolean
12+
| number
13+
| bigint
14+
| string
15+
| Uint8Array
16+
| Buffer
17+
| null
18+
| ScValJsInput[]
19+
| Map<ScValJsInput, ScValJsInput>
20+
| Record<string, ScValJsInput>
21+
| { __type: string; value: unknown };
22+
23+
export interface ScValTypeHint {
24+
type: string;
25+
element?: ScValTypeHint;
26+
key?: ScValTypeHint;
27+
value?: ScValTypeHint;
28+
}
29+
30+
export interface SerializationResult {
31+
scVal: xdr.ScVal;
32+
rawXdr: string;
33+
xdrType: string;
34+
}
35+
36+
export interface RoundTripResult<T = unknown> {
37+
original: T;
38+
encoded: SerializationResult;
39+
decoded: unknown;
40+
matches: boolean;
41+
}
42+
43+
const INTEGER_TYPES = new Set(['u32', 'i32', 'u64', 'i64', 'u128', 'i128', 'u256', 'i256']);
44+
45+
/** Encodes a JavaScript value to an ScVal using an explicit type hint. */
46+
export function jsToScVal(value: unknown, hint: ScValTypeHint): xdr.ScVal {
47+
if (hint.type === 'option') {
48+
if (value === null || value === undefined) {
49+
return xdr.ScVal.scvVoid();
50+
}
51+
return jsToScVal(value, hint.element ?? { type: 'val' });
52+
}
53+
54+
if (INTEGER_TYPES.has(hint.type) && typeof value === 'number') {
55+
return nativeToScVal(BigInt(value), { type: hint.type as any });
56+
}
57+
58+
return nativeToScVal(value as any, hint as any);
59+
}
60+
61+
/** Decodes an ScVal into a JSON-safe JavaScript value. */
62+
export function scValToJs(scVal: xdr.ScVal): unknown {
63+
const native = scValToNative(scVal);
64+
return formatJsValue(native);
65+
}
66+
67+
/** Converts native SDK values into JSON-safe representations. */
68+
export function formatJsValue(value: unknown): unknown {
69+
if (typeof value === 'bigint') return value.toString();
70+
if (value instanceof Uint8Array) return `0x${Buffer.from(value).toString('hex')}`;
71+
if (Array.isArray(value)) return value.map(formatJsValue);
72+
if (value instanceof Map) {
73+
const out: Record<string, unknown> = {};
74+
for (const [key, entry] of value.entries()) {
75+
out[String(formatJsValue(key))] = formatJsValue(entry);
76+
}
77+
return out;
78+
}
79+
if (value && typeof value === 'object') {
80+
const out: Record<string, unknown> = {};
81+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
82+
out[key] = formatJsValue(entry);
83+
}
84+
return out;
85+
}
86+
return value;
87+
}
88+
89+
/** Serializes a value and returns the ScVal plus base64 XDR. */
90+
export function serializeValue(value: unknown, hint: ScValTypeHint): SerializationResult {
91+
const scVal = jsToScVal(value, hint);
92+
return {
93+
scVal,
94+
rawXdr: scVal.toXDR('base64'),
95+
xdrType: scVal.switch().name,
96+
};
97+
}
98+
99+
/** Encodes then decodes a value and reports whether the round-trip matches. */
100+
export function roundTrip<T>(value: T, hint: ScValTypeHint): RoundTripResult<T> {
101+
const encoded = serializeValue(value, hint);
102+
const decoded = scValToJs(encoded.scVal);
103+
const matches = stableStringify(decoded) === stableStringify(formatJsValue(value));
104+
return { original: value, encoded, decoded, matches };
105+
}
106+
107+
/** Stable JSON comparison helper for round-trip checks. */
108+
export function stableStringify(value: unknown): string {
109+
return JSON.stringify(value, (_key, current) => {
110+
if (typeof current === 'bigint') return current.toString();
111+
if (current instanceof Uint8Array) return `0x${Buffer.from(current).toString('hex')}`;
112+
return current;
113+
});
114+
}
115+
116+
/** Encodes a Stellar address string to scvAddress. */
117+
export function encodeAddress(value: string): xdr.ScVal {
118+
if (!StrKey.isValidEd25519PublicKey(value) && !StrKey.isValidContract(value)) {
119+
throw new TypeError(`Invalid Stellar address: ${value}`);
120+
}
121+
return Address.fromString(value).toScVal();
122+
}
123+
124+
/** Encodes nested map/vector structures from plain JS objects. */
125+
export function encodeNested(value: Record<string, unknown>): xdr.ScVal {
126+
const entries = Object.entries(value).map(([key, entry]) => {
127+
if (typeof entry === 'boolean') {
128+
return new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(key), val: xdr.ScVal.scvBool(entry) });
129+
}
130+
if (typeof entry === 'number') {
131+
return new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(key), val: xdr.ScVal.scvU32(entry) });
132+
}
133+
if (typeof entry === 'string') {
134+
return new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(key), val: xdr.ScVal.scvString(entry) });
135+
}
136+
if (typeof entry === 'bigint') {
137+
return new xdr.ScMapEntry({
138+
key: xdr.ScVal.scvSymbol(key),
139+
val: nativeToScVal(entry, { type: 'i128' }),
140+
});
141+
}
142+
if (Array.isArray(entry)) {
143+
return new xdr.ScMapEntry({
144+
key: xdr.ScVal.scvSymbol(key),
145+
val: nativeToScVal(entry, { type: 'vec', element: { type: 'u32' } }),
146+
});
147+
}
148+
if (entry && typeof entry === 'object') {
149+
return new xdr.ScMapEntry({
150+
key: xdr.ScVal.scvSymbol(key),
151+
val: encodeNested(entry as Record<string, unknown>),
152+
});
153+
}
154+
throw new TypeError(`Unsupported nested value for key "${key}"`);
155+
});
156+
157+
return xdr.ScVal.scvMap(entries);
158+
}
159+
160+
/** Returns a human-readable explanation when a JS type cannot be encoded. */
161+
export function describeUnsupportedJsType(value: unknown): string {
162+
if (value === undefined) {
163+
return 'undefined is not encodable; use null with an Option<T> hint or omit the field.';
164+
}
165+
if (typeof value === 'function') {
166+
return 'Functions cannot be encoded as ScVal.';
167+
}
168+
if (value instanceof Date) {
169+
return 'Date objects are not supported; encode as a u64 timestamp or ISO string.';
170+
}
171+
return `Type "${Object.prototype.toString.call(value)}" is not supported by nativeToScVal.`;
172+
}
173+
174+
/** Attempts encoding and returns either a SerializationResult or an error message. */
175+
export function trySerialize(
176+
value: unknown,
177+
hint: ScValTypeHint,
178+
): { ok: true; result: SerializationResult } | { ok: false; error: string } {
179+
try {
180+
return { ok: true, result: serializeValue(value, hint) };
181+
} catch (error: any) {
182+
return { ok: false, error: error?.message || describeUnsupportedJsType(value) };
183+
}
184+
}

0 commit comments

Comments
 (0)