Skip to content
Merged
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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ members = [
"contracts/*",
"e2e"
]
exclude = [
"contracts/compound_fees",
"contracts/flash_loan_guard",
"contracts/yield_vault",
]
default-members = ["contracts/*"]

[profile.release]
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ You can also generate a minimal file with `bc-forge config init`. The example co
| `symbol` | Yes | Token symbol. |
| `decimals` | No | Token decimal precision, from 0 to 18. Defaults to `7`. |
| `admin` | No | Stellar public `G...` address that administers the token. Required when initializing a contract. |
| `superAdmin` | No | Stellar public `G...` address assigned the initial `SuperAdmin` role during RBAC initialization. Defaults to `admin` when omitted. |
| `network` | No | Deployment environment: `mainnet`, `testnet`, `futurenet`, `standalone`, or `custom`. Defaults to `testnet`. |
| `rpcUrl` | No | Soroban RPC endpoint URL. |
| `networkPassphrase` | No | Stellar network passphrase. |
Expand Down Expand Up @@ -216,6 +217,41 @@ stellar contract invoke \
--symbol "SFG"
```

### Initialize RBAC (Assign Initial SuperAdmin)

After initialization, run the `init_rbac` step to bootstrap role-based access
control and assign the initial `SuperAdmin` role:

```bash
# Bootstrap the SuperAdmin mapping from the configured admin (idempotent)
stellar contract invoke \
--id <CONTRACT_ID> \
--source deployer \
--network testnet \
-- \
migrate_admin

# Assign the initial SuperAdmin role (the contract admin can perform this grant)
stellar contract invoke \
--id <CONTRACT_ID> \
--source deployer \
--network testnet \
-- \
grant_role \
--caller <YOUR_PUBLIC_KEY> \
--role SuperAdmin \
--address <SUPER_ADMIN_PUBLIC_KEY>

# Verify the assignment
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
has_role \
--role SuperAdmin \
--address <SUPER_ADMIN_PUBLIC_KEY>
```

### Mint Tokens

```bash
Expand Down
5 changes: 5 additions & 0 deletions cli/src/schema/bc-forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"pattern": "^G[A-Z2-7]{55}$",
"description": "Stellar G-address of the contract admin"
},
"superAdmin": {
"type": "string",
"pattern": "^G[A-Z2-7]{55}$",
"description": "Stellar G-address assigned the initial SuperAdmin role during RBAC init"
},
"network": {
"type": "string",
"enum": ["mainnet", "testnet", "futurenet", "standalone", "local", "custom"],
Expand Down
1 change: 1 addition & 0 deletions cli/src/utils/config-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface BcForgeConfig {
symbol: string;
decimals?: number;
admin?: string;
superAdmin?: string;
network?: 'mainnet' | 'testnet' | 'futurenet' | 'standalone' | 'local' | 'custom' | string;
rpcUrl?: string;
networkPassphrase?: string;
Expand Down
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"symbol": "BFG",
"decimals": 7,
"admin": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"superAdmin": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"network": "testnet",
"rpcUrl": "https://soroban-testnet.stellar.org",
"networkPassphrase": "Test SDF Network ; September 2015",
Expand Down
2 changes: 1 addition & 1 deletion contracts/admin/src/tests/proptest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ proptest! {
}

for (i, role) in GRANTABLE_ROLES.iter().enumerate() {
let still_held = (mask >> i) & 1 == 1 && !((revoke_mask >> i) & 1 == 1);
let still_held = (mask >> i) & 1 == 1 && ((revoke_mask >> i) & 1 != 1);
prop_assert_eq!(client.has_role(role, &holder), still_held);
}
}
Expand Down
2 changes: 0 additions & 2 deletions contracts/token/src/fuzz_mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
//! range of amounts against unauthorized callers, asserting that only an
//! authorized minter can ever create tokens.

#![cfg(test)]

extern crate std;

use crate::{BcForgeToken, BcForgeTokenClient};
Expand Down
2 changes: 0 additions & 2 deletions contracts/token/src/lockup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
//! the error-adjacent states the helpers must handle: a user with no lock at
//! all, and a lock whose unlock timestamp has already passed (expired).

#![cfg(test)]

use crate::{BcForgeToken, BcForgeTokenClient, DataKey, LockupState};
use soroban_sdk::testutils::{Address as _, Ledger as _};
use soroban_sdk::{Address, Env, String};
Expand Down
2 changes: 0 additions & 2 deletions contracts/token/src/storage_collisions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
//! so `approve`, the rate-limit counters and a guarded `mint` are exercised
//! against the slots initialization created.

#![cfg(test)]

use crate::reentrancy_guard::ReentrancyGuardState;
use crate::{BcForgeToken, BcForgeTokenClient, DataKey, TokenError};
use bc_forge_admin::{AdminKey, Role};
Expand Down
11 changes: 9 additions & 2 deletions docs/ACCESS_CONTROL.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,19 @@ SDK methods for RBAC:

| Method | Description |
| --- | --- |
| `initRbac(superAdmin, source)` | `init_rbac` deployment step: runs `migrate_admin`, then grants the initial `SuperAdmin` role |
| `grantSuperAdmin(address, source)` | Assign the SuperAdmin role (calls `grant_role`) |
| `revokeSuperAdmin(address, source)` | Revoke the SuperAdmin role (calls `revoke_role`) |
| `grantMinter(address, source)` | Grant Minter role (calls `grant_role` on contract) |
| `revokeMinter(address, source)` | Revoke Minter role (calls `revoke_role` on contract) |
| `hasRole(role, address)` | Check role membership (calls `has_role`) |

The `source` parameter must be a `Keypair` with the appropriate role. For
`grant_role`, the caller must hold `SuperAdmin`. The SDK serializes roles using
`nativeToScVal` for on-chain compatibility.
`grant_role`, the caller must hold `SuperAdmin`. The configured contract admin
implicitly satisfies this, so the admin keypair can bootstrap the hierarchy
with `initRbac` immediately after `initialize`. The SDK serializes roles as
symbol `ScVal`s (`SuperAdmin`, `Minter`, …) for on-chain compatibility with the
contract's `Role` enum.

## Key invariants

Expand Down
31 changes: 31 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,33 @@ await client.initialize(
console.log('Contract initialized');
```

## RBAC Initialization (SuperAdmin)

After `initialize`, run the `init_rbac` deployment step to bootstrap role-based
access control and assign the initial `SuperAdmin`:

```typescript
import { bcForgeClient, Role } from '@bc-forge/sdk';

const adminKeypair = Keypair.fromSecret('SXXX...SECRET');

// One-time RBAC bootstrap: migrate_admin + grant SuperAdmin role
const rbac = await client.initRbac(adminKeypair.publicKey(), adminKeypair);
console.log('migrate_admin TX:', rbac.migrate.hash, 'Success:', rbac.migrate.success);
console.log('grant_role TX:', rbac.grant.hash, 'Success:', rbac.grant.success);

// Verify the assignment
const isSuperAdmin = await client.hasRole(Role.SuperAdmin, adminKeypair.publicKey());
console.log('Is SuperAdmin:', isSuperAdmin);
```

You can also assign or revoke the role independently:

```typescript
await client.grantSuperAdmin('GOTHER...ADMIN', adminKeypair);
await client.revokeSuperAdmin('GOTHER...ADMIN', adminKeypair);
```

## Batch Minting

```typescript
Expand Down Expand Up @@ -395,6 +422,7 @@ await client.unpause(adminKeypair);
| `getVersion()` | `string` | Contract version |
| `getBalances(addresses[], batchSize)` | `bigint[]` | Batch query multiple balances |
| `getEvents(startLedger?)` | `any[]` | Get contract events |
| `hasRole(role, address)` | `boolean` | Check whether an address holds a role |

### Write Methods (require Keypair)

Expand Down Expand Up @@ -439,6 +467,9 @@ When a `walletAdapter` is configured and connected, write methods may be invoked
| `updateSymbol(newSymbol, source)` | Update token symbol (admin-only) |
| `lockTokens(user, amount, unlockTime, source)` | Lock tokens for vesting |
| `withdrawLocked(user, source)` | Withdraw matured locked tokens |
| `initRbac(superAdmin, source)` | `init_rbac` step: migrate_admin + grant initial SuperAdmin |
| `grantSuperAdmin(address, source)` | Grant the SuperAdmin role |
| `revokeSuperAdmin(address, source)` | Revoke the SuperAdmin role |

### Offline Transaction Builders

Expand Down
185 changes: 185 additions & 0 deletions sdk/src/client.rbac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* @bc-forge/sdk — Tests for RBAC initialization methods
*
* Covers the `init_rbac` deployment step (`initRbac`), the initial SuperAdmin
* assignment (`grantSuperAdmin` / `revokeSuperAdmin`), and the `hasRole` view.
*/

import { jest } from '@jest/globals';
import { Keypair, Networks, xdr } from '@stellar/stellar-sdk';
import { bcForgeClient, Role } from './client';
import type { TransactionResult } from './client';
import { addressToScVal } from './utils';

const MOCK_RPC_URL = 'https://soroban-testnet.stellar.org';
const MOCK_NETWORK = Networks.TESTNET;
const MOCK_CONTRACT_ID = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526';

type InvokeContractMock = jest.Mock<
(method: string, args: unknown[], source: Keypair) => Promise<TransactionResult>
>;

function makeClient() {
return new bcForgeClient({
rpcUrl: MOCK_RPC_URL,
networkPassphrase: MOCK_NETWORK,
contractId: MOCK_CONTRACT_ID,
});
}

describe('bcForgeClient RBAC init', () => {
let client: bcForgeClient;
let adminKeypair: Keypair;

beforeEach(() => {
client = makeClient();
adminKeypair = Keypair.random();
});

describe('grantSuperAdmin', () => {
it('invokes grant_role with the SuperAdmin role and target address', async () => {
const target = Keypair.random().publicKey();
const invokeContract = jest.fn(async () => ({
success: true,
hash: 'mock-hash',
returnValue: null,
}));
(client as unknown as { invokeContract: InvokeContractMock }).invokeContract =
invokeContract as unknown as InvokeContractMock;

const result = await client.grantSuperAdmin(target, adminKeypair);

expect(result).toEqual({ success: true, hash: 'mock-hash', returnValue: null });
expect(invokeContract).toHaveBeenCalledTimes(1);
const [method, args, source] = invokeContract.mock.calls[0] as unknown as [
string,
xdr.ScVal[],
Keypair,
];
expect(method).toBe('grant_role');
expect(args).toHaveLength(3);
expect(args[0].toXDR('base64')).toBe(
addressToScVal(adminKeypair.publicKey()).toXDR('base64'),
);
expect(args[1].sym().toString()).toBe(Role.SuperAdmin);
expect(args[2].toXDR('base64')).toBe(addressToScVal(target).toXDR('base64'));
expect(source).toBe(adminKeypair);
});

it('propagates a failed grant_role transaction as an unsuccessful result', async () => {
const invokeContract = jest.fn(async () => ({ success: false, hash: 'failed-hash' }));
(client as unknown as { invokeContract: InvokeContractMock }).invokeContract =
invokeContract as unknown as InvokeContractMock;

const result = await client.grantSuperAdmin(Keypair.random().publicKey(), adminKeypair);

expect(result.success).toBe(false);
expect(result.hash).toBe('failed-hash');
});
});

describe('revokeSuperAdmin', () => {
it('invokes revoke_role with the SuperAdmin role and target address', async () => {
const target = Keypair.random().publicKey();
const invokeContract = jest.fn(async () => ({
success: true,
hash: 'mock-hash',
returnValue: null,
}));
(client as unknown as { invokeContract: InvokeContractMock }).invokeContract =
invokeContract as unknown as InvokeContractMock;

const result = await client.revokeSuperAdmin(target, adminKeypair);

expect(result.success).toBe(true);
const [method, args, source] = invokeContract.mock.calls[0] as unknown as [
string,
xdr.ScVal[],
Keypair,
];
expect(method).toBe('revoke_role');
expect(args[1].sym().toString()).toBe(Role.SuperAdmin);
expect(args[2].toXDR('base64')).toBe(addressToScVal(target).toXDR('base64'));
expect(source).toBe(adminKeypair);
});

it('returns the unsuccessful result when the contract rejects the revoke', async () => {
const invokeContract = jest.fn(async () => ({ success: false, hash: 'revoke-failed' }));
(client as unknown as { invokeContract: InvokeContractMock }).invokeContract =
invokeContract as unknown as InvokeContractMock;

const result = await client.revokeSuperAdmin(Keypair.random().publicKey(), adminKeypair);

expect(result.success).toBe(false);
expect(result.hash).toBe('revoke-failed');
});
});

describe('hasRole', () => {
it('returns true when the contract reports the role is held', async () => {
const target = Keypair.random().publicKey();
const queryContract = jest.fn(async () => xdr.ScVal.scvBool(true));
(client as unknown as { queryContract: typeof queryContract }).queryContract = queryContract;

await expect(client.hasRole(Role.SuperAdmin, target)).resolves.toBe(true);
const [method, args] = queryContract.mock.calls[0] as unknown as [string, xdr.ScVal[]];
expect(method).toBe('has_role');
expect(args[0].sym().toString()).toBe(Role.SuperAdmin);
expect(args[1].toXDR('base64')).toBe(addressToScVal(target).toXDR('base64'));
});

it('returns false when the contract reports the role is not held', async () => {
const queryContract = jest.fn(async () => xdr.ScVal.scvBool(false));
(client as unknown as { queryContract: typeof queryContract }).queryContract = queryContract;

await expect(client.hasRole(Role.Minter, Keypair.random().publicKey())).resolves.toBe(false);
});
});

describe('initRbac', () => {
it('runs migrate_admin then grant_role(SuperAdmin) as the init_rbac step', async () => {
const superAdmin = Keypair.random().publicKey();
const calls: Array<[string, xdr.ScVal[]]> = [];
const invokeContract = jest.fn(async (method: string, args: unknown[]) => {
calls.push([method, args as xdr.ScVal[]]);
return { success: true, hash: `hash-${method}`, returnValue: null };
});
(client as unknown as { invokeContract: InvokeContractMock }).invokeContract =
invokeContract as unknown as InvokeContractMock;

const result = await client.initRbac(superAdmin, adminKeypair);

expect(calls).toHaveLength(2);
expect(calls[0][0]).toBe('migrate_admin');
expect(calls[0][1]).toHaveLength(0);

expect(calls[1][0]).toBe('grant_role');
const grantArgs = calls[1][1];
expect(grantArgs).toHaveLength(3);
expect(grantArgs[0].toXDR('base64')).toBe(
addressToScVal(adminKeypair.publicKey()).toXDR('base64'),
);
expect(grantArgs[1].sym().toString()).toBe(Role.SuperAdmin);
expect(grantArgs[2].toXDR('base64')).toBe(addressToScVal(superAdmin).toXDR('base64'));

expect(result.migrate.success).toBe(true);
expect(result.grant.success).toBe(true);
});

it('reports the grant failure when the SuperAdmin assignment is rejected', async () => {
const invokeContract = jest.fn(async (method: string) =>
method === 'grant_role'
? { success: false, hash: 'grant-failed' }
: { success: true, hash: 'migrate-ok', returnValue: null },
);
(client as unknown as { invokeContract: InvokeContractMock }).invokeContract =
invokeContract as unknown as InvokeContractMock;

const result = await client.initRbac(Keypair.random().publicKey(), adminKeypair);

expect(result.migrate.success).toBe(true);
expect(result.grant.success).toBe(false);
expect(result.grant.hash).toBe('grant-failed');
});
});
});
Loading
Loading