Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,51 @@ jobs:

- name: Run tests
run: npm test

# ─── Codegen CI ─────────────────────────────────────────────────────────
codegen:
name: Contract Bindings Codegen
runs-on: ubuntu-latest
needs: [contracts, sdk]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Cache Cargo target
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }}-stable

- name: Build WASM
run: cargo build --target wasm32-unknown-unknown --release

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: sdk/package-lock.json

- name: Build codegen tool
working-directory: sdk
run: npm run codegen:build

- name: Run codegen
working-directory: sdk
run: |
node codegen/dist/index.js \
../target/wasm32-unknown-unknown/release/bc_forge_token.wasm \
src/generated-client.ts \
--name BcForgeToken

- name: Upload generated bindings
uses: actions/upload-artifact@v4
with:
name: generated-client
path: sdk/src/generated-client.ts
1 change: 0 additions & 1 deletion contracts/admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ pub fn create_proposal(env: &Env, creator: Address, description: String) -> u64

let proposal = Proposal {
creator: creator.clone(),
action_type,
description,
approvals: vec![env, creator],
executed: false,
Expand Down
66 changes: 15 additions & 51 deletions contracts/token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ pub enum DataKey {
/// The contract admin address (singular).
Admin,
PendingAdmin,
/// Spending allowance: (owner, spender) → amount and expiration.
/// Spending allowance: (owner, spender) → AllowanceInfo.
Allowance(Address, Address),
/// Token balance for an address.
Allowance(Address, Address),
AllowanceExp(Address, Address),
Balance(Address),
Name,
Symbol,
Expand Down Expand Up @@ -133,54 +130,26 @@ impl BcForgeToken {
.set(&DataKey::Balance(id.clone()), &balance);
}

fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 {
let allowance_info: AllowanceInfo = env.storage()
.persistent()
.get(&DataKey::Allowance(from.clone(), spender.clone()))
.unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 });

// Check if allowance has expired
if allowance_info.exp_ledger > 0 {
let current_ledger = env.ledger().sequence();
if current_ledger > allowance_info.exp_ledger as u64 {
return 0; // Allowance expired
}
}

allowance_info.amount
if let Some(exp_ledger) = env
.storage()
.persistent()
.get::<_, u32>(&DataKey::AllowanceExp(from.clone(), spender.clone()))
{
if exp_ledger > 0 && env.ledger().sequence() > exp_ledger {
return 0;
}
}

fn read_allowance_info(env: &Env, from: &Address, spender: &Address) -> AllowanceInfo {
env.storage()
.persistent()
.get(&DataKey::Allowance(from.clone(), spender.clone()))
.unwrap_or(0)
.unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 })
}

fn write_allowance(env: &Env, from: &Address, spender: &Address, amount: i128, exp: u32) {
let allowance_info = AllowanceInfo { amount, exp_ledger: exp };
env.storage()
.persistent()
.set(&DataKey::Allowance(from.clone(), spender.clone()), &allowance_info);
fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 {
let info = Self::read_allowance_info(env, from, spender);
if info.exp_ledger > 0 && env.ledger().sequence() > info.exp_ledger {
return 0;
}
info.amount
}

/// Reads the full allowance info for (owner → spender), defaulting to zero allowance with no expiration.
fn read_allowance_info(env: &Env, from: &Address, spender: &Address) -> AllowanceInfo {
env.storage()
.persistent()
.get(&DataKey::Allowance(from.clone(), spender.clone()))
.unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 })
.set(&DataKey::Allowance(from.clone(), spender.clone()), &amount);
env.storage()
.persistent()
.set(&DataKey::AllowanceExp(from.clone(), spender.clone()), &exp);
fn write_allowance(env: &Env, from: &Address, spender: &Address, amount: i128, exp: u32) {
env.storage().persistent().set(
&DataKey::Allowance(from.clone(), spender.clone()),
&AllowanceInfo { amount, exp_ledger: exp },
);
}

fn move_balance(
Expand Down Expand Up @@ -615,12 +584,9 @@ impl TokenInterface for BcForgeToken {
soroban_sdk::panic_with_error!(&env, TokenError::InsufficientAllowance);
}

Self::move_balance(&env, &from, &to, amount);
// Preserve the original expiration
let allowance_info = Self::read_allowance_info(&env, &from, &spender);
Self::write_allowance(&env, &from, &spender, allowance - amount, allowance_info.exp_ledger);
let _ = Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount));
Self::write_allowance(&env, &from, &spender, allowance - amount, 0);
Self::write_allowance(&env, &from, &spender, allowance - amount, allowance_info.exp_ledger);
events::emit_transfer_from(&env, &spender, &from, &to, amount, allowance - amount);
}

Expand Down Expand Up @@ -664,10 +630,8 @@ impl TokenInterface for BcForgeToken {
soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance);
}

// Preserve the original expiration
let allowance_info = Self::read_allowance_info(&env, &from, &spender);
Self::write_allowance(&env, &from, &spender, allowance - amount, allowance_info.exp_ledger);
Self::write_allowance(&env, &from, &spender, allowance - amount, 0);
Self::write_balance(&env, &from, balance - amount);
let supply = Self::read_supply(&env) - amount;
Self::write_supply(&env, supply);
Expand Down
149 changes: 149 additions & 0 deletions sdk/codegen/dist/generate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"use strict";
/**
* generate.ts
*
* Generates type-safe TypeScript client code from a parsed Soroban contract ABI.
* Produces:
* - TypeScript interfaces for all struct/union/enum types
* - A typed client class with one method per contract function
* - An error enum for all contract error cases
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateBindings = generateBindings;
// ─── Type Mapping ─────────────────────────────────────────────────────────────
function scSpecTypeToTs(typeDef) {
const kind = typeDef.switch().name;
switch (kind) {
case 'scSpecTypeVoid': return 'void';
case 'scSpecTypeBool': return 'boolean';
case 'scSpecTypeU32': return 'number';
case 'scSpecTypeI32': return 'number';
case 'scSpecTypeU64': return 'bigint';
case 'scSpecTypeI64': return 'bigint';
case 'scSpecTypeU128': return 'bigint';
case 'scSpecTypeI128': return 'bigint';
case 'scSpecTypeU256': return 'bigint';
case 'scSpecTypeI256': return 'bigint';
case 'scSpecTypeString': return 'string';
case 'scSpecTypeSymbol': return 'string';
case 'scSpecTypeAddress': return 'string';
case 'scSpecTypeBytes': return 'Buffer';
case 'scSpecTypeBytesN': return 'Buffer';
case 'scSpecTypeTimepoint': return 'bigint';
case 'scSpecTypeDuration': return 'bigint';
case 'scSpecTypeVal': return 'unknown';
case 'scSpecTypeError': return 'number';
case 'scSpecTypeOption': {
const inner = scSpecTypeToTs(typeDef.option().valueType());
return `${inner} | null`;
}
case 'scSpecTypeResult': {
const ok = scSpecTypeToTs(typeDef.result().okType());
const err = scSpecTypeToTs(typeDef.result().errorType());
return `{ ok: ${ok} } | { error: ${err} }`;
}
case 'scSpecTypeVec': {
const elem = scSpecTypeToTs(typeDef.vec().elementType());
return `${elem}[]`;
}
case 'scSpecTypeMap': {
const k = scSpecTypeToTs(typeDef.map().keyType());
const v = scSpecTypeToTs(typeDef.map().valueType());
return `Map<${k}, ${v}>`;
}
case 'scSpecTypeTuple': {
const elems = typeDef.tuple().valueTypes().map(scSpecTypeToTs);
return `[${elems.join(', ')}]`;
}
case 'scSpecTypeUdt': {
return typeDef.udt().name().toString();
}
default:
return 'unknown';
}
}
// ─── Struct / Union / Enum Interfaces ─────────────────────────────────────────
function generateStructInterface(s) {
const doc = s.doc ? `/** ${s.doc} */\n` : '';
const fields = s.fields
.map((f) => ` ${f.name}: ${scSpecTypeToTs(f.type)};`)
.join('\n');
return `${doc}export interface ${s.name} {\n${fields}\n}`;
}
function generateUnionType(u) {
const doc = u.doc ? `/** ${u.doc} */\n` : '';
const cases = u.cases.map((c) => {
if (c.types.length === 0) {
return ` | { tag: '${c.name}' }`;
}
const vals = c.types.map((t, i) => `value${i}: ${scSpecTypeToTs(t)}`).join('; ');
return ` | { tag: '${c.name}'; ${vals} }`;
});
return `${doc}export type ${u.name} =\n${cases.join('\n')};`;
}
function generateEnumType(e) {
const doc = e.doc ? `/** ${e.doc} */\n` : '';
const cases = e.cases.map((c) => ` ${c.name} = ${c.value},`).join('\n');
return `${doc}export enum ${e.name} {\n${cases}\n}`;
}
// ─── Error Enum ───────────────────────────────────────────────────────────────
function generateErrorEnum(errors, contractName) {
if (errors.length === 0)
return '';
const cases = errors.map((e) => {
const doc = e.doc ? ` /** ${e.doc} */\n` : '';
return `${doc} ${e.name} = ${e.value},`;
}).join('\n');
return `export enum ${contractName}Error {\n${cases}\n}`;
}
// ─── Method Signatures ────────────────────────────────────────────────────────
function generateMethod(fn) {
const doc = fn.doc
? ` /**\n * ${fn.doc}\n */\n`
: '';
const params = fn.inputs
.map((inp) => `${inp.name}: ${scSpecTypeToTs(inp.type)}`)
.join(', ');
const returnType = fn.outputs.length === 0
? 'void'
: fn.outputs.length === 1
? scSpecTypeToTs(fn.outputs[0])
: `[${fn.outputs.map(scSpecTypeToTs).join(', ')}]`;
return `${doc} ${fn.name}(${params}): Promise<${returnType}>;`;
}
// ─── Client Interface ─────────────────────────────────────────────────────────
function generateClientInterface(funcs, contractName) {
const methods = funcs.map(generateMethod).join('\n\n');
return `export interface ${contractName}Client {\n${methods}\n}`;
}
// ─── Main Generator ───────────────────────────────────────────────────────────
function generateBindings(abi, contractName) {
const banner = [
'// ─────────────────────────────────────────────────────────────────────────────',
`// Generated by @bc-forge/codegen — DO NOT EDIT`,
`// Contract: ${contractName}`,
`// Generated: ${new Date().toISOString()}`,
'// ─────────────────────────────────────────────────────────────────────────────',
'',
].join('\n');
const parts = [banner];
// Enums
for (const e of abi.enums) {
parts.push(generateEnumType(e));
}
// Structs
for (const s of abi.structs) {
parts.push(generateStructInterface(s));
}
// Unions
for (const u of abi.unions) {
parts.push(generateUnionType(u));
}
// Error enum
const errorEnum = generateErrorEnum(abi.errors, contractName);
if (errorEnum)
parts.push(errorEnum);
// Client interface
parts.push(generateClientInterface(abi.funcs, contractName));
return parts.join('\n\n') + '\n';
}
Loading
Loading