diff --git a/cli/src/__tests__/check-status.test.ts b/cli/src/__tests__/check-status.test.ts new file mode 100644 index 00000000..413bdfe5 --- /dev/null +++ b/cli/src/__tests__/check-status.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { StrKey } from '@stellar/stellar-sdk'; +import { + checkStatus, + collectContracts, + pingContract, + type StatusChecker +} from '../commands/check-status.js'; +import type { BcForgeConfig } from '../utils/config-parser.js'; + +const TOKEN_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); +const WRAPPER_ID = StrKey.encodeContract(Buffer.alloc(32, 2)); + +/** Builds a stub RPC server whose getLedgerEntries behaviour is scripted per call. */ +function stubServer( + impl: (...keys: any[]) => Promise<{ entries: unknown[] }> +): StatusChecker { + return { getLedgerEntries: vi.fn(impl) as any } as StatusChecker; +} + +/** Deterministic clock producing a fixed 25ms delta per measured span. */ +function fakeClock(step = 25) { + let current = 0; + return () => { + const value = current; + current += step; + return value; + }; +} + +function baseConfig(contracts: BcForgeConfig['contracts']): BcForgeConfig { + return { + name: 'Test Token', + symbol: 'TTK', + network: 'testnet', + contracts + }; +} + +describe('CLI check-status command (#699)', () => { + describe('collectContracts', () => { + it('returns every contract declared in the configuration', () => { + const config = baseConfig({ + token: { contractId: TOKEN_ID }, + wrapper: { contractId: WRAPPER_ID } + }); + + const collected = collectContracts(config); + + expect(collected).toHaveLength(2); + expect(collected.map(c => c.name).sort()).toEqual(['token', 'wrapper']); + }); + + it('returns an empty list when no contracts are declared', () => { + expect(collectContracts(baseConfig(undefined))).toEqual([]); + }); + }); + + describe('pingContract', () => { + it('reports a responsive contract with measured latency', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const report = await pingContract( + server, + 'token', + { contractId: TOKEN_ID }, + fakeClock() + ); + + expect(report.status).toBe('responsive'); + expect(report.contractId).toBe(TOKEN_ID); + expect(report.latencyMs).toBe(25); + expect(report.error).toBeUndefined(); + }); + + it('reports not_deployed when the RPC returns no instance entry', async () => { + const server = stubServer(async () => ({ entries: [] })); + + const report = await pingContract( + server, + 'token', + { contractId: TOKEN_ID }, + fakeClock() + ); + + expect(report.status).toBe('not_deployed'); + expect(report.error).toMatch(/No contract instance/); + expect(report.latencyMs).toBe(25); + }); + + it('reports unreachable when the RPC call rejects', async () => { + const server = stubServer(async () => { + throw new Error('connect ECONNREFUSED'); + }); + + const report = await pingContract( + server, + 'token', + { contractId: TOKEN_ID }, + fakeClock() + ); + + expect(report.status).toBe('unreachable'); + expect(report.error).toMatch(/ECONNREFUSED/); + expect(report.latencyMs).toBe(25); + }); + + it('reports invalid when the configured contract id is malformed', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const report = await pingContract( + server, + 'token', + { contractId: 'NOT-A-CONTRACT-ID' }, + fakeClock() + ); + + expect(report.status).toBe('invalid'); + expect(server.getLedgerEntries).not.toHaveBeenCalled(); + }); + + it('reports invalid when no contract id is configured', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const report = await pingContract(server, 'token', {}, fakeClock()); + + expect(report.status).toBe('invalid'); + expect(report.error).toMatch(/No contractId configured/); + expect(server.getLedgerEntries).not.toHaveBeenCalled(); + }); + }); + + describe('checkStatus', () => { + it('marks all responsive when every contract answers', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + const config = baseConfig({ + token: { contractId: TOKEN_ID }, + wrapper: { contractId: WRAPPER_ID } + }); + + const result = await checkStatus(server, config, 'https://rpc.example', fakeClock()); + + expect(result.allResponsive).toBe(true); + expect(result.reports).toHaveLength(2); + expect(result.network).toBe('testnet'); + expect(result.rpcUrl).toBe('https://rpc.example'); + }); + + it('marks not all responsive when a single contract fails', async () => { + const server = stubServer(async () => ({ entries: [] })); + const config = baseConfig({ + token: { contractId: TOKEN_ID }, + wrapper: { contractId: WRAPPER_ID } + }); + + const result = await checkStatus(server, config, 'https://rpc.example', fakeClock()); + + expect(result.allResponsive).toBe(false); + expect(result.reports.every(r => r.status === 'not_deployed')).toBe(true); + }); + + it('is not responsive when the configuration declares no contracts', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const result = await checkStatus( + server, + baseConfig(undefined), + 'https://rpc.example', + fakeClock() + ); + + expect(result.reports).toEqual([]); + expect(result.allResponsive).toBe(false); + }); + }); +}); diff --git a/cli/src/__tests__/generate-bindings.test.ts b/cli/src/__tests__/generate-bindings.test.ts new file mode 100644 index 00000000..f9e5b272 --- /dev/null +++ b/cli/src/__tests__/generate-bindings.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + buildBindingsArgs, + generateBindings, + resolveBinary, + BindingsOptionError, + SUPPORTED_LANGUAGES, + type CommandRunner +} from '../commands/generate-bindings.js'; + +const CONTRACT_ID = 'CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR'; + +/** Runner stub that records the invocation and returns a scripted result. */ +function stubRunner( + result: Partial<{ exitCode: number | null; stdout: string; stderr: string }> = {} +) { + return vi.fn(async () => ({ + exitCode: result.exitCode ?? 0, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '' + })) as unknown as CommandRunner & ReturnType; +} + +describe('CLI generate-bindings command (#701)', () => { + describe('buildBindingsArgs', () => { + it('builds a typescript invocation from a local wasm artifact', () => { + const args = buildBindingsArgs({ + language: 'typescript', + wasm: './token.wasm', + outputDir: './packages/token' + }); + + expect(args).toEqual([ + 'contract', + 'bindings', + 'typescript', + '--wasm', + './token.wasm', + '--output-dir', + './packages/token' + ]); + }); + + it('passes network options when generating from a deployed contract', () => { + const args = buildBindingsArgs({ + language: 'typescript', + contractId: CONTRACT_ID, + outputDir: './out', + rpcUrl: 'https://rpc.example', + networkPassphrase: 'Test SDF Network ; September 2015' + }); + + expect(args).toContain('--contract-id'); + expect(args).toContain(CONTRACT_ID); + expect(args).toContain('--rpc-url'); + expect(args).toContain('https://rpc.example'); + expect(args).toContain('--network-passphrase'); + }); + + it('omits network options when generating from a local wasm', () => { + const args = buildBindingsArgs({ + language: 'typescript', + wasm: './token.wasm', + outputDir: './out', + rpcUrl: 'https://rpc.example', + networkPassphrase: 'Test SDF Network ; September 2015' + }); + + expect(args).not.toContain('--rpc-url'); + expect(args).not.toContain('--network-passphrase'); + }); + + it('appends --overwrite when requested', () => { + const args = buildBindingsArgs({ + language: 'typescript', + wasm: './token.wasm', + outputDir: './out', + overwrite: true + }); + + expect(args).toContain('--overwrite'); + }); + + it('builds a rust invocation with only the wasm flag', () => { + const args = buildBindingsArgs({ language: 'rust', wasm: './token.wasm' }); + + expect(args).toEqual(['contract', 'bindings', 'rust', '--wasm', './token.wasm']); + expect(args).not.toContain('--output-dir'); + }); + + it('supports every language the Stellar CLI exposes', () => { + for (const language of SUPPORTED_LANGUAGES) { + const args = buildBindingsArgs({ + language, + wasm: './token.wasm', + outputDir: './out' + }); + expect(args.slice(0, 3)).toEqual(['contract', 'bindings', language]); + } + }); + + it('rejects an unsupported language', () => { + expect(() => + buildBindingsArgs({ language: 'cobol', wasm: './t.wasm', outputDir: './out' }) + ).toThrow(BindingsOptionError); + }); + + it('rejects a missing contract source', () => { + expect(() => buildBindingsArgs({ language: 'typescript', outputDir: './out' })).toThrow( + /exactly one of --wasm/ + ); + }); + + it('rejects more than one contract source', () => { + expect(() => + buildBindingsArgs({ + language: 'typescript', + wasm: './t.wasm', + contractId: CONTRACT_ID, + outputDir: './out' + }) + ).toThrow(/mutually exclusive/); + }); + + it('rejects a missing output directory for languages that require one', () => { + expect(() => buildBindingsArgs({ language: 'typescript', wasm: './t.wasm' })).toThrow( + /--output-dir is required/ + ); + }); + + it('rejects a network source for the rust generator, which reads local wasm only', () => { + expect(() => + buildBindingsArgs({ language: 'rust', contractId: CONTRACT_ID }) + ).toThrow(/reads a local build only/); + }); + }); + + describe('resolveBinary', () => { + it('defaults to the stellar binary', () => { + const previous = { ...process.env }; + delete process.env.STELLAR_CLI_BIN; + delete process.env.SOROBAN_CLI_BIN; + + expect(resolveBinary()).toBe('stellar'); + + process.env = previous; + }); + + it('honours an overridden binary path', () => { + const previous = process.env.STELLAR_CLI_BIN; + process.env.STELLAR_CLI_BIN = '/opt/soroban'; + + expect(resolveBinary()).toBe('/opt/soroban'); + + if (previous === undefined) delete process.env.STELLAR_CLI_BIN; + else process.env.STELLAR_CLI_BIN = previous; + }); + }); + + describe('generateBindings', () => { + it('reports success when the generator exits cleanly', async () => { + const runner = stubRunner({ exitCode: 0, stdout: 'Generated!' }); + + const result = await generateBindings( + { language: 'typescript', wasm: './token.wasm', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Generated!'); + expect(runner).toHaveBeenCalledWith('stellar', [ + 'contract', + 'bindings', + 'typescript', + '--wasm', + './token.wasm', + '--output-dir', + './out' + ]); + }); + + it('reports failure and surfaces stderr when the generator exits non-zero', async () => { + const runner = stubRunner({ exitCode: 1, stderr: 'error: invalid wasm' }); + + const result = await generateBindings( + { language: 'typescript', wasm: './bad.wasm', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + expect(result.error).toMatch(/exited with code 1/); + expect(result.error).toMatch(/invalid wasm/); + }); + + it('reports an install hint when the Stellar CLI is not on PATH', async () => { + const runner = vi.fn(async () => { + const err: any = new Error('spawn stellar ENOENT'); + err.code = 'ENOENT'; + throw err; + }) as unknown as CommandRunner; + + const result = await generateBindings( + { language: 'typescript', wasm: './token.wasm', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Install the Stellar CLI/); + expect(result.error).toMatch(/STELLAR_CLI_BIN/); + }); + + it('does not invoke the generator when the options are invalid', async () => { + const runner = stubRunner(); + + const result = await generateBindings( + { language: 'typescript', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/exactly one of --wasm/); + expect(runner).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/src/__tests__/verify-hash.test.ts b/cli/src/__tests__/verify-hash.test.ts new file mode 100644 index 00000000..efe0b42e Binary files /dev/null and b/cli/src/__tests__/verify-hash.test.ts differ diff --git a/cli/src/commands/check-status.ts b/cli/src/commands/check-status.ts new file mode 100644 index 00000000..bf6c0629 --- /dev/null +++ b/cli/src/commands/check-status.ts @@ -0,0 +1,171 @@ +import { Command } from 'commander'; +import { Contract, rpc as SorobanRpc } from '@stellar/stellar-sdk'; +import { getClientConfig, loadConfigFile } from '../utils/config.js'; +import logger from '../utils/logger.js'; +import type { BcForgeConfig, ContractDeploymentConfig } from '../utils/config-parser.js'; + +export type ContractStatus = 'responsive' | 'unreachable' | 'not_deployed' | 'invalid'; + +export interface ContractStatusReport { + name: string; + contractId?: string; + status: ContractStatus; + latencyMs?: number; + error?: string; +} + +export interface CheckStatusResult { + network?: string; + rpcUrl: string; + reports: ContractStatusReport[]; + allResponsive: boolean; +} + +export interface StatusChecker { + getLedgerEntries: SorobanRpc.Server['getLedgerEntries']; +} + +/** + * Collects the deployed contracts declared under `contracts` in .bc-forge.json. + */ +export function collectContracts( + config: BcForgeConfig +): Array<{ name: string; deployment: ContractDeploymentConfig }> { + const contracts = config.contracts; + if (!contracts) return []; + + return Object.entries(contracts).map(([name, deployment]) => ({ + name, + deployment: deployment ?? {} + })); +} + +/** + * Pings a single contract by reading its instance ledger entry. A contract that + * returns an instance entry is deployed and served by the RPC node; a missing + * entry means the ID is not deployed on this network. + */ +export async function pingContract( + server: StatusChecker, + name: string, + deployment: ContractDeploymentConfig, + now: () => number = () => Date.now() +): Promise { + const contractId = deployment.contractId; + + if (!contractId) { + return { + name, + status: 'invalid', + error: 'No contractId configured' + }; + } + + let footprint; + try { + footprint = new Contract(contractId).getFootprint(); + } catch (err: any) { + return { + name, + contractId, + status: 'invalid', + error: err.message + }; + } + + const startedAt = now(); + try { + const response = await server.getLedgerEntries(footprint); + const latencyMs = now() - startedAt; + + if (!response.entries || response.entries.length === 0) { + return { + name, + contractId, + status: 'not_deployed', + latencyMs, + error: 'No contract instance found on this network' + }; + } + + return { name, contractId, status: 'responsive', latencyMs }; + } catch (err: any) { + return { + name, + contractId, + status: 'unreachable', + latencyMs: now() - startedAt, + error: err.message + }; + } +} + +/** + * Pings every configured contract and reports latency and reachability. + */ +export async function checkStatus( + server: StatusChecker, + config: BcForgeConfig, + rpcUrl: string, + now: () => number = () => Date.now() +): Promise { + const contracts = collectContracts(config); + + const reports = await Promise.all( + contracts.map(({ name, deployment }) => pingContract(server, name, deployment, now)) + ); + + return { + network: config.network, + rpcUrl, + reports, + allResponsive: reports.length > 0 && reports.every(r => r.status === 'responsive') + }; +} + +/** + * Builds the `check-status` command. + */ +export function createCheckStatusCommand(): Command { + return new Command('check-status') + .description('Ping all deployed contracts and report latency and status') + .option('-c, --config ', 'Path to a .bc-forge.json deployment configuration file') + .action(async (options) => { + try { + const parsed = loadConfigFile(options.config); + if (!parsed.success || !parsed.config) { + parsed.errors?.forEach(err => logger.error(` - ${err}`)); + throw new Error('Failed to load deployment configuration'); + } + + const clientConfig = getClientConfig(); + logger.debug(`Pinging contracts via RPC: ${clientConfig.rpcUrl}`); + + const server = new SorobanRpc.Server(clientConfig.rpcUrl); + const status = await checkStatus(server, parsed.config, clientConfig.rpcUrl); + + if (status.reports.length === 0) { + logger.warn('No contracts declared under "contracts" in the configuration file.'); + return; + } + + logger.info(`Network: ${status.network ?? 'unknown'} (${status.rpcUrl})`); + for (const report of status.reports) { + const latency = report.latencyMs !== undefined ? ` ${report.latencyMs}ms` : ''; + const target = report.contractId ?? 'no contract id'; + if (report.status === 'responsive') { + logger.success(`${report.name}: responsive${latency} [${target}]`); + } else { + logger.error(`${report.name}: ${report.status}${latency} [${target}] - ${report.error}`); + } + } + + if (!status.allResponsive) { + process.exitCode = 1; + } + } catch (err: any) { + logger.error(`Error: ${err.message}`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/commands/generate-bindings.ts b/cli/src/commands/generate-bindings.ts new file mode 100644 index 00000000..5745ffff --- /dev/null +++ b/cli/src/commands/generate-bindings.ts @@ -0,0 +1,254 @@ +import { spawn } from 'node:child_process'; +import { Command } from 'commander'; +import { getClientConfig } from '../utils/config.js'; +import logger from '../utils/logger.js'; + +export type BindingsLanguage = + | 'typescript' + | 'rust' + | 'python' + | 'java' + | 'flutter' + | 'swift' + | 'php'; + +export const SUPPORTED_LANGUAGES: BindingsLanguage[] = [ + 'typescript', + 'rust', + 'python', + 'java', + 'flutter', + 'swift', + 'php' +]; + +/** + * `stellar contract bindings rust` reads a local wasm only and writes to + * stdout, so it accepts neither an output directory nor a network source. + */ +const WASM_ONLY_LANGUAGES: BindingsLanguage[] = ['rust']; + +export interface GenerateBindingsOptions { + language: string; + wasm?: string; + wasmHash?: string; + contractId?: string; + outputDir?: string; + overwrite?: boolean; + rpcUrl?: string; + networkPassphrase?: string; + network?: string; +} + +export interface BindingsResult { + success: boolean; + command: string; + args: string[]; + exitCode: number | null; + stdout: string; + stderr: string; + error?: string; +} + +export interface CommandRunner { + (command: string, args: string[]): Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>; +} + +export class BindingsOptionError extends Error {} + +/** + * Resolves the soroban CLI binary. The tool was renamed `soroban` -> `stellar`, + * so the binary is overridable for environments still on the older name. + */ +export function resolveBinary(): string { + return process.env.STELLAR_CLI_BIN || process.env.SOROBAN_CLI_BIN || 'stellar'; +} + +/** + * Builds the argument vector for `stellar contract bindings `. + * + * Validates the option combination up front so a misuse is reported by this + * CLI directly instead of surfacing as an opaque subprocess failure. + */ +export function buildBindingsArgs(options: GenerateBindingsOptions): string[] { + const language = options.language as BindingsLanguage; + + if (!SUPPORTED_LANGUAGES.includes(language)) { + throw new BindingsOptionError( + `Unsupported bindings language: ${options.language}. Supported: ${SUPPORTED_LANGUAGES.join(', ')}` + ); + } + + const sources = [options.wasm, options.wasmHash, options.contractId].filter(Boolean); + if (sources.length === 0) { + throw new BindingsOptionError( + 'A contract source is required: provide exactly one of --wasm, --wasm-hash or --contract-id' + ); + } + if (sources.length > 1) { + throw new BindingsOptionError( + 'Provide exactly one contract source: --wasm, --wasm-hash and --contract-id are mutually exclusive' + ); + } + + const args = ['contract', 'bindings', language]; + + if (WASM_ONLY_LANGUAGES.includes(language)) { + if (!options.wasm) { + throw new BindingsOptionError( + `The ${language} generator reads a local build only: use --wasm` + ); + } + args.push('--wasm', options.wasm); + return args; + } + + if (!options.outputDir) { + throw new BindingsOptionError(`--output-dir is required for ${language} bindings`); + } + + if (options.wasm) args.push('--wasm', options.wasm); + if (options.wasmHash) args.push('--wasm-hash', options.wasmHash); + if (options.contractId) args.push('--contract-id', options.contractId); + + args.push('--output-dir', options.outputDir); + if (options.overwrite) args.push('--overwrite'); + + // Only a network-sourced generation needs to reach an RPC node. + if (!options.wasm) { + if (options.network) args.push('--network', options.network); + if (options.rpcUrl) args.push('--rpc-url', options.rpcUrl); + if (options.networkPassphrase) { + args.push('--network-passphrase', options.networkPassphrase); + } + } + + return args; +} + +/** Default runner: spawns the soroban CLI and collects its output. */ +export const spawnRunner: CommandRunner = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', chunk => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', chunk => { + stderr += chunk.toString(); + }); + + child.on('error', reject); + child.on('close', exitCode => resolve({ exitCode, stdout, stderr })); + }); + +/** + * Runs `stellar contract bindings ` and reports the outcome. + */ +export async function generateBindings( + options: GenerateBindingsOptions, + runner: CommandRunner = spawnRunner, + binary: string = resolveBinary() +): Promise { + let args: string[]; + try { + args = buildBindingsArgs(options); + } catch (err: any) { + return { + success: false, + command: binary, + args: [], + exitCode: null, + stdout: '', + stderr: '', + error: err.message + }; + } + + try { + const { exitCode, stdout, stderr } = await runner(binary, args); + return { + success: exitCode === 0, + command: binary, + args, + exitCode, + stdout, + stderr, + error: + exitCode === 0 + ? undefined + : `${binary} exited with code ${exitCode}${stderr ? `: ${stderr.trim()}` : ''}` + }; + } catch (err: any) { + const notFound = err?.code === 'ENOENT'; + return { + success: false, + command: binary, + args, + exitCode: null, + stdout: '', + stderr: '', + error: notFound + ? `Could not run "${binary}". Install the Stellar CLI (https://developers.stellar.org/docs/tools/cli) or set STELLAR_CLI_BIN to its path.` + : err.message + }; + } +} + +/** + * Builds the `generate-bindings` command. + */ +export function createGenerateBindingsCommand(): Command { + return new Command('generate-bindings') + .description('Generate contract client bindings via the Stellar CLI code generator') + .option('-l, --language ', `Target language (${SUPPORTED_LANGUAGES.join(', ')})`, 'typescript') + .option('--wasm ', 'Local .wasm artifact to generate from') + .option('--wasm-hash ', 'Hash of a WASM blob already uploaded to the network') + .option('--contract-id ', 'Deployed contract to generate from') + .option('-o, --output-dir ', 'Directory to write the generated package into') + .option('--overwrite', 'Overwrite the output directory if it already exists') + .action(async (options) => { + try { + const clientConfig = getClientConfig(); + const contractId = options.contractId + || (!options.wasm && !options.wasmHash ? clientConfig.contractId : undefined); + + logger.debug(`Generating ${options.language} bindings`); + + const result = await generateBindings({ + language: options.language, + wasm: options.wasm, + wasmHash: options.wasmHash, + contractId, + outputDir: options.outputDir, + overwrite: options.overwrite, + rpcUrl: clientConfig.rpcUrl, + networkPassphrase: clientConfig.networkPassphrase + }); + + logger.debug(`Running: ${result.command} ${result.args.join(' ')}`); + if (result.stdout.trim()) logger.info(result.stdout.trim()); + + if (result.success) { + logger.success( + options.outputDir + ? `Generated ${options.language} bindings in ${options.outputDir}` + : `Generated ${options.language} bindings` + ); + } else { + logger.error(result.error ?? 'Bindings generation failed'); + process.exitCode = 1; + } + } catch (err: any) { + logger.error(`Error: ${err.message}`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/commands/verify-hash.ts b/cli/src/commands/verify-hash.ts new file mode 100644 index 00000000..7c5788e1 --- /dev/null +++ b/cli/src/commands/verify-hash.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import { Command } from 'commander'; +import { Contract, xdr, rpc as SorobanRpc } from '@stellar/stellar-sdk'; +import { getClientConfig } from '../utils/config.js'; +import logger from '../utils/logger.js'; + +export type HashVerdict = 'match' | 'mismatch' | 'missing_local' | 'missing_onchain' | 'invalid'; + +export interface HashComparison { + name: string; + contractId?: string; + wasmPath?: string; + localHash?: string; + onChainHash?: string; + verdict: HashVerdict; + error?: string; +} + +export interface HashFetcher { + getLedgerEntries: SorobanRpc.Server['getLedgerEntries']; +} + +/** + * Computes the Soroban WASM hash of a local build artifact. + * + * Soroban identifies uploaded contract code by the SHA-256 of the raw .wasm + * bytes, so hashing the file reproduces exactly the hash stored on-chain. + */ +export function hashLocalWasm(wasmPath: string): string { + const bytes = fs.readFileSync(wasmPath); + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Extracts the WASM hash referenced by a contract instance ledger entry. + * + * Returns undefined for Stellar-asset contracts, which have no uploaded WASM. + */ +export function extractOnChainHash(entryData: xdr.LedgerEntryData): string | undefined { + if (entryData.switch().name !== 'contractData') return undefined; + + const val = entryData.contractData().val(); + if (val.switch().name !== 'scvContractInstance') return undefined; + + const executable = val.instance().executable(); + if (executable.switch().name !== 'contractExecutableWasm') return undefined; + + return executable.wasmHash().toString('hex'); +} + +/** + * Fetches the WASM hash a deployed contract currently runs. + */ +export async function fetchOnChainHash( + server: HashFetcher, + contractId: string +): Promise { + const footprint = new Contract(contractId).getFootprint(); + const response = await server.getLedgerEntries(footprint); + + const entry = response.entries?.[0]; + if (!entry) return undefined; + + return extractOnChainHash(entry.val); +} + +/** + * Diffs a local build artifact against the WASM hash a deployed contract runs. + */ +export async function verifyHash( + server: HashFetcher, + name: string, + contractId: string | undefined, + wasmPath: string | undefined +): Promise { + if (!contractId) { + return { name, wasmPath, verdict: 'invalid', error: 'No contractId configured' }; + } + if (!wasmPath) { + return { name, contractId, verdict: 'invalid', error: 'No local WASM path provided' }; + } + + let localHash: string; + try { + localHash = hashLocalWasm(wasmPath); + } catch (err: any) { + return { + name, + contractId, + wasmPath, + verdict: 'missing_local', + error: `Could not read local WASM: ${err.message}` + }; + } + + let onChainHash: string | undefined; + try { + onChainHash = await fetchOnChainHash(server, contractId); + } catch (err: any) { + return { + name, + contractId, + wasmPath, + localHash, + verdict: 'invalid', + error: err.message + }; + } + + if (!onChainHash) { + return { + name, + contractId, + wasmPath, + localHash, + verdict: 'missing_onchain', + error: 'No WASM hash found on-chain for this contract' + }; + } + + return { + name, + contractId, + wasmPath, + localHash, + onChainHash, + verdict: localHash === onChainHash ? 'match' : 'mismatch' + }; +} + +/** + * Builds the `verify-hash` command. + */ +export function createVerifyHashCommand(): Command { + return new Command('verify-hash') + .description('Diff a local WASM build against the hash a deployed contract runs') + .requiredOption('--wasm ', 'Path to the locally built .wasm artifact') + .option('--contract-id ', 'Contract to verify against (defaults to the configured contract)') + .option('--name ', 'Label for the contract in the report', 'contract') + .action(async (options) => { + try { + const clientConfig = getClientConfig(); + const contractId = options.contractId || clientConfig.contractId; + + logger.debug(`Verifying ${options.wasm} against ${contractId}`); + + const server = new SorobanRpc.Server(clientConfig.rpcUrl); + const result = await verifyHash(server, options.name, contractId, options.wasm); + + if (result.localHash) logger.info(`Local hash: ${result.localHash}`); + if (result.onChainHash) logger.info(`On-chain hash: ${result.onChainHash}`); + + if (result.verdict === 'match') { + logger.success(`${result.name}: local build matches the deployed contract`); + } else { + logger.error(`${result.name}: ${result.verdict}${result.error ? ` - ${result.error}` : ''}`); + process.exitCode = 1; + } + } catch (err: any) { + logger.error(`Error: ${err.message}`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/parseArgs.ts b/cli/src/parseArgs.ts index 74f6903f..45e02fdc 100644 --- a/cli/src/parseArgs.ts +++ b/cli/src/parseArgs.ts @@ -1,6 +1,9 @@ import { Command } from "commander"; import { createUpgradeCommand } from "./commands/upgrade.js"; import { createSmokeTestCommand } from "./commands/smoke-test.js"; +import { createCheckStatusCommand } from "./commands/check-status.js"; +import { createVerifyHashCommand } from "./commands/verify-hash.js"; +import { createGenerateBindingsCommand } from "./commands/generate-bindings.js"; const VERSION = "0.1.0"; @@ -20,7 +23,10 @@ export function buildProgram(): Command { program .addCommand(createUpgradeCommand()) - .addCommand(createSmokeTestCommand()); + .addCommand(createSmokeTestCommand()) + .addCommand(createCheckStatusCommand()) + .addCommand(createVerifyHashCommand()) + .addCommand(createGenerateBindingsCommand()); return program; } diff --git a/contracts/token/test_snapshots/test_address_without_roles_can_neither_mint_nor_pause.1.json b/contracts/token/test_snapshots/test_address_without_roles_can_neither_mint_nor_pause.1.json new file mode 100644 index 00000000..988d267c --- /dev/null +++ b/contracts/token/test_snapshots/test_address_without_roles_can_neither_mint_nor_pause.1.json @@ -0,0 +1,237 @@ +{ + "generators": { + "address": 4, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "role_chk" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "vec": [ + { + "symbol": "Pauser" + } + ] + }, + { + "bool": false + } + ] + } + } + } + }, + "failed_call": true + } + ] +} \ No newline at end of file diff --git a/contracts/token/test_snapshots/test_exercising_one_role_preserves_the_other.1.json b/contracts/token/test_snapshots/test_exercising_one_role_preserves_the_other.1.json new file mode 100644 index 00000000..25d619dd --- /dev/null +++ b/contracts/token/test_snapshots/test_exercising_one_role_preserves_the_other.1.json @@ -0,0 +1,569 @@ +{ + "generators": { + "address": 4, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 500 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "pause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "unpause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 250 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "symbol": "mint_guard" + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "symbol": "mint_guard" + }, + "durability": "persistent", + "val": { + "vec": [ + { + "symbol": "NotEntered" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "i128": { + "hi": 0, + "lo": 750 + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 10 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 750 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/token/test_snapshots/test_multi_role_address_can_mint_and_pause.1.json b/contracts/token/test_snapshots/test_multi_role_address_can_mint_and_pause.1.json new file mode 100644 index 00000000..e2fd5722 --- /dev/null +++ b/contracts/token/test_snapshots/test_multi_role_address_can_mint_and_pause.1.json @@ -0,0 +1,511 @@ +{ + "generators": { + "address": 4, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 1000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "pause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "unpause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "symbol": "mint_guard" + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "symbol": "mint_guard" + }, + "durability": "persistent", + "val": { + "vec": [ + { + "symbol": "NotEntered" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "i128": { + "hi": 0, + "lo": 1000 + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 10 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/token/test_snapshots/test_multi_role_address_holds_both_roles.1.json b/contracts/token/test_snapshots/test_multi_role_address_holds_both_roles.1.json new file mode 100644 index 00000000..3a841ab0 --- /dev/null +++ b/contracts/token/test_snapshots/test_multi_role_address_holds_both_roles.1.json @@ -0,0 +1,248 @@ +{ + "generators": { + "address": 4, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 10 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/token/test_snapshots/test_multi_role_assignment_is_isolated_per_address.1.json b/contracts/token/test_snapshots/test_multi_role_assignment_is_isolated_per_address.1.json new file mode 100644 index 00000000..66c600e3 --- /dev/null +++ b/contracts/token/test_snapshots/test_multi_role_assignment_is_isolated_per_address.1.json @@ -0,0 +1,248 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 10 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/token/test_snapshots/test_revoking_one_role_leaves_the_other_intact.1.json b/contracts/token/test_snapshots/test_revoking_one_role_leaves_the_other_intact.1.json new file mode 100644 index 00000000..9f893a99 --- /dev/null +++ b/contracts/token/test_snapshots/test_revoking_one_role_leaves_the_other_intact.1.json @@ -0,0 +1,401 @@ +{ + "generators": { + "address": 4, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "pause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "unpause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 8 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "role_chk" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "vec": [ + { + "symbol": "Minter" + } + ] + }, + { + "bool": false + } + ] + } + } + } + }, + "failed_call": true + } + ] +} \ No newline at end of file diff --git a/contracts/token/test_snapshots/test_single_role_address_cannot_exercise_the_other_role.1.json b/contracts/token/test_snapshots/test_single_role_address_cannot_exercise_the_other_role.1.json new file mode 100644 index 00000000..8ad06c00 --- /dev/null +++ b/contracts/token/test_snapshots/test_single_role_address_cannot_exercise_the_other_role.1.json @@ -0,0 +1,503 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 100 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "pause", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "symbol": "mint_guard" + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "symbol": "mint_guard" + }, + "durability": "persistent", + "val": { + "vec": [ + { + "symbol": "NotEntered" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "i128": { + "hi": 0, + "lo": 100 + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 2 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "RoleMask" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 8 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Decimals" + } + ] + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "vec": [ + { + "symbol": "MaxSupply" + } + ] + }, + "val": { + "i128": { + "hi": 9223372036854775807, + "lo": 18446744073709551615 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Name" + } + ] + }, + "val": { + "string": "bc-forge Token" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "Supply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 100 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Symbol" + } + ] + }, + "val": { + "string": "SFG" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/token/tests/multi_role_e2e.rs b/contracts/token/tests/multi_role_e2e.rs new file mode 100644 index 00000000..7b256d5b --- /dev/null +++ b/contracts/token/tests/multi_role_e2e.rs @@ -0,0 +1,210 @@ +#![cfg(test)] + +//! Integration coverage for a single address holding several roles at once. +//! +//! Role membership is stored as one bitmask per address under +//! `AdminKey::RoleMask`, so granting a second role is a bitwise OR onto the +//! existing mask. These tests exercise that an address granted both `Minter` +//! and `Pauser` can actually execute a mint and a pause through the token +//! contract, that neither role displaces the other, and that revoking one +//! leaves the other intact. + +use bc_forge_admin::{Role, ROLE_BIT_MINTER, ROLE_BIT_PAUSER}; +use bc_forge_token::{BcForgeToken, BcForgeTokenClient, TokenError}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, String}; + +/// Deploys and initializes a token contract, returning the client, the admin +/// and a fresh address holding no roles yet. +fn setup<'a>(env: &'a Env) -> (BcForgeTokenClient<'a>, Address, Address, Address) { + env.mock_all_auths(); + + let contract_id = env.register(BcForgeToken, ()); + let client = BcForgeTokenClient::new(env, &contract_id); + + let admin = Address::generate(env); + let operator = Address::generate(env); + let recipient = Address::generate(env); + + client.initialize( + &admin, + &7, + &String::from_str(env, "bc-forge Token"), + &String::from_str(env, "SFG"), + ); + + (client, admin, operator, recipient) +} + +/// Writes `mask` as the role bitmask for `address`, mirroring how `grant_role` +/// persists a combined assignment. +fn set_role_mask(env: &Env, contract_id: &Address, address: &Address, mask: u32) { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .set(&bc_forge_admin::AdminKey::RoleMask(address.clone()), &mask); + }); +} + +/// Reads the role bitmask currently stored for `address`. +fn get_role_mask(env: &Env, contract_id: &Address, address: &Address) -> u32 { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .get::<_, u32>(&bc_forge_admin::AdminKey::RoleMask(address.clone())) + .unwrap_or(0) + }) +} + +/// Grants both Minter and Pauser to one address by OR-ing the two role bits. +fn grant_minter_and_pauser(env: &Env, contract_id: &Address, address: &Address) { + set_role_mask(env, contract_id, address, ROLE_BIT_MINTER | ROLE_BIT_PAUSER); +} + +/// A single address granted Minter and Pauser holds both roles simultaneously. +#[test] +fn test_multi_role_address_holds_both_roles() { + let env = Env::default(); + let (client, _admin, operator, _recipient) = setup(&env); + grant_minter_and_pauser(&env, &client.address, &operator); + + env.as_contract(&client.address, || { + assert!(bc_forge_admin::has_role(&env, Role::Minter, &operator)); + assert!(bc_forge_admin::has_role(&env, Role::Pauser, &operator)); + // Roles that were never granted must remain absent. + assert!(!bc_forge_admin::has_role(&env, Role::SuperAdmin, &operator)); + }); + + // Both bits live in the same mask entry. + let mask = get_role_mask(&env, &client.address, &operator); + assert_eq!(mask, ROLE_BIT_MINTER | ROLE_BIT_PAUSER); +} + +/// The core acceptance path: one address exercises both roles end to end by +/// minting and then pausing the contract. +#[test] +fn test_multi_role_address_can_mint_and_pause() { + let env = Env::default(); + let (client, _admin, operator, recipient) = setup(&env); + grant_minter_and_pauser(&env, &client.address, &operator); + + // Exercise the Minter role. + client.mint(&operator, &recipient, &1_000); + assert_eq!(client.balance(&recipient), 1_000); + assert_eq!(client.supply(), 1_000); + + // Exercise the Pauser role from the very same address. + client.pause(&operator); + assert!(env.as_contract(&client.address, || bc_forge_lifecycle::is_paused(&env))); + + // The pause is effective: a transfer is rejected while paused. + let transfer_res = client.try_transfer(&recipient, &operator, &100); + assert!(transfer_res.is_err()); + if let Err(Ok(err)) = transfer_res { + assert_eq!(err, TokenError::ContractPaused.into()); + } + + // And the same address can lift the pause again. + client.unpause(&operator); + assert!(!env.as_contract(&client.address, || bc_forge_lifecycle::is_paused(&env))); +} + +/// Exercising one role must not consume or disturb the other. +#[test] +fn test_exercising_one_role_preserves_the_other() { + let env = Env::default(); + let (client, _admin, operator, recipient) = setup(&env); + grant_minter_and_pauser(&env, &client.address, &operator); + + client.mint(&operator, &recipient, &500); + + // After minting, the Pauser role is still held and still usable. + assert_eq!( + get_role_mask(&env, &client.address, &operator), + ROLE_BIT_MINTER | ROLE_BIT_PAUSER + ); + client.pause(&operator); + client.unpause(&operator); + + // Minting still works after the pause cycle. + client.mint(&operator, &recipient, &250); + assert_eq!(client.balance(&recipient), 750); +} + +/// Revoking one role leaves the other in place and enforced. +#[test] +fn test_revoking_one_role_leaves_the_other_intact() { + let env = Env::default(); + let (client, _admin, operator, recipient) = setup(&env); + grant_minter_and_pauser(&env, &client.address, &operator); + + // Drop only the Minter bit, mirroring a revoke of that single role. + set_role_mask(&env, &client.address, &operator, ROLE_BIT_PAUSER); + + env.as_contract(&client.address, || { + assert!(!bc_forge_admin::has_role(&env, Role::Minter, &operator)); + assert!(bc_forge_admin::has_role(&env, Role::Pauser, &operator)); + }); + + // The retained Pauser role still works. + client.pause(&operator); + assert!(env.as_contract(&client.address, || bc_forge_lifecycle::is_paused(&env))); + client.unpause(&operator); + + // The revoked Minter role is genuinely gone. + let res = client.try_mint(&operator, &recipient, &100); + assert!(res.is_err()); +} + +/// An address holding only one of the two roles cannot exercise the other. +#[test] +fn test_single_role_address_cannot_exercise_the_other_role() { + let env = Env::default(); + let (client, _admin, operator, recipient) = setup(&env); + + // Minter only: minting works, pausing does not. + set_role_mask(&env, &client.address, &operator, ROLE_BIT_MINTER); + client.mint(&operator, &recipient, &100); + + let pause_res = client.try_pause(&operator); + assert!(pause_res.is_err()); + + // Pauser only: pausing works, minting does not. + let pauser_only = Address::generate(&env); + set_role_mask(&env, &client.address, &pauser_only, ROLE_BIT_PAUSER); + + let mint_res = client.try_mint(&pauser_only, &recipient, &100); + assert!(mint_res.is_err()); + + client.pause(&pauser_only); + assert!(env.as_contract(&client.address, || bc_forge_lifecycle::is_paused(&env))); +} + +/// Multi-role assignments are per address and never bleed across addresses. +#[test] +fn test_multi_role_assignment_is_isolated_per_address() { + let env = Env::default(); + let (client, _admin, operator, _recipient) = setup(&env); + grant_minter_and_pauser(&env, &client.address, &operator); + + let bystander = Address::generate(&env); + + env.as_contract(&client.address, || { + assert!(!bc_forge_admin::has_role(&env, Role::Minter, &bystander)); + assert!(!bc_forge_admin::has_role(&env, Role::Pauser, &bystander)); + }); + assert_eq!(get_role_mask(&env, &client.address, &bystander), 0); +} + +/// An address with no roles at all can exercise neither. +#[test] +fn test_address_without_roles_can_neither_mint_nor_pause() { + let env = Env::default(); + let (client, _admin, operator, recipient) = setup(&env); + + let mint_res = client.try_mint(&operator, &recipient, &100); + assert!(mint_res.is_err()); + + let pause_res = client.try_pause(&operator); + assert!(pause_res.is_err()); +}