diff --git a/packages/contracts/contracts/linkora-contracts/src/lib.rs b/packages/contracts/contracts/linkora-contracts/src/lib.rs index 6addb6ce..f478a1fe 100644 --- a/packages/contracts/contracts/linkora-contracts/src/lib.rs +++ b/packages/contracts/contracts/linkora-contracts/src/lib.rs @@ -66,7 +66,7 @@ pub enum StorageKey { PostReportersIdx(u64, u32), // persistent: (post_id, seq) -> Address (Count is ReportCount) PostTipCooldownsCount(u64), // persistent: post_id -> u32 PostTipCooldownsIdx(u64, u32), // persistent: (post_id, seq) -> Address - UpgradeProposal, // instance: staged WASM upgrade proposal + UpgradeProposal, // instance: staged WASM upgrade proposal } // ── Instance-storage key constants (small scalars, not contracttype) ────────── @@ -1104,7 +1104,7 @@ impl LinkoraContract { Self::bump(&env, &author_key); } - let remaining_entries: u32 = f_count + following_count + author_posts.len() as u32; + let remaining_entries: u32 = f_count + following_count + author_posts.len(); if f_count == 0 && following_count == 0 && author_posts.is_empty() { env.storage().persistent().remove(&tombstone_key); @@ -3449,13 +3449,20 @@ impl LinkoraContract { upgrader.require_auth(); validate_non_default_address(&env, "upgrader", &upgrader); Self::require_role(&env, &upgrader, Role::Upgrader); - require_with_error!(&env, new_wasm_hash != BytesN::from_array(&env, &[0u8; 32]), "wasm hash must not be empty"); + require_with_error!( + &env, + new_wasm_hash != BytesN::from_array(&env, &[0u8; 32]), + "wasm hash must not be empty" + ); let proposed_ledger = env.ledger().sequence(); - env.storage().instance().set(&StorageKey::UpgradeProposal, &UpgradeProposal { - new_wasm_hash, - proposed_ledger, - executable_ledger: proposed_ledger.saturating_add(UPGRADE_TIMELOCK_LEDGERS), - }); + env.storage().instance().set( + &StorageKey::UpgradeProposal, + &UpgradeProposal { + new_wasm_hash, + proposed_ledger, + executable_ledger: proposed_ledger.saturating_add(UPGRADE_TIMELOCK_LEDGERS), + }, + ); } /// Executes the previously proposed contract WASM upgrade after the timelock. @@ -3465,15 +3472,32 @@ impl LinkoraContract { validate_non_default_address(&env, "upgrader", &upgrader); Self::require_role(&env, &upgrader, Role::Upgrader); Self::require_not_paused(&env); - let proposal: UpgradeProposal = env.storage().instance().get(&StorageKey::UpgradeProposal).expect("upgrade not proposed"); - require_with_error!(&env, env.ledger().sequence() >= proposal.executable_ledger, "upgrade timelock not elapsed"); + let proposal: UpgradeProposal = env + .storage() + .instance() + .get(&StorageKey::UpgradeProposal) + .expect("upgrade not proposed"); + require_with_error!( + &env, + env.ledger().sequence() >= proposal.executable_ledger, + "upgrade timelock not elapsed" + ); let mut state: ContractState = env.storage().instance().get(&CONTRACT_STATE).unwrap(); - state.version = state.version.checked_add(1).expect("contract version overflow"); + state.version = state + .version + .checked_add(1) + .expect("contract version overflow"); state.implementation_wasm_hash = Some(proposal.new_wasm_hash.clone()); env.storage().instance().set(&CONTRACT_STATE, &state); - env.deployer().update_current_contract_wasm(proposal.new_wasm_hash.clone()); - env.storage().instance().remove(&StorageKey::UpgradeProposal); - ContractUpgraded { new_wasm_hash: proposal.new_wasm_hash }.publish(&env); + env.deployer() + .update_current_contract_wasm(proposal.new_wasm_hash.clone()); + env.storage() + .instance() + .remove(&StorageKey::UpgradeProposal); + ContractUpgraded { + new_wasm_hash: proposal.new_wasm_hash, + } + .publish(&env); } /// Deprecated immediate-upgrade entrypoint. Upgrades must use diff --git a/packages/contracts/contracts/linkora-contracts/src/test.rs b/packages/contracts/contracts/linkora-contracts/src/test.rs index a4158f88..d05a1d3e 100644 --- a/packages/contracts/contracts/linkora-contracts/src/test.rs +++ b/packages/contracts/contracts/linkora-contracts/src/test.rs @@ -2141,6 +2141,47 @@ fn test_set_fee_non_admin_panics() { client.set_fee(&outsider, &100); } +#[test] +fn test_set_fee_max_boundary_valid() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _) = setup_contract(&env); + + // Set fee to the maximum allowed (100%) should succeed. + client.set_fee(&admin, &10_000); + assert_eq!(client.get_fee_bps(), 10_000); +} + +#[test] +#[should_panic(expected = "fee_bps must be between 0 and 10000")] +fn test_set_fee_rejects_value_above_max() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _) = setup_contract(&env); + + // 20_000 bps = 200%, which would make fee computation exceed the + // transferred amount. Must be rejected, not clamped or silently accepted. + client.set_fee(&admin, &20_000); +} + +#[test] +fn test_set_fee_rejects_value_above_max_leaves_stored_fee_unchanged() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _) = setup_contract(&env); + + client.set_fee(&admin, &250); + assert_eq!(client.get_fee_bps(), 250); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.set_fee(&admin, &20_000); + })); + assert!(result.is_err(), "fee_bps above 10000 must panic"); + + // Invariant: a rejected update must not mutate the previously stored fee. + assert_eq!(client.get_fee_bps(), 250); +} + // ── Username validation tests (issue #195) ─────────────────────────────────────── #[test] @@ -7985,4 +8026,3 @@ fn test_batch_cleanup_post_emits_event_summary() { client.batch_cleanup_post(&post_id, &10); assert!(client.get_post(&post_id).is_none()); } - diff --git a/packages/contracts/contracts/linkora-contracts/src/tests/invariants.rs b/packages/contracts/contracts/linkora-contracts/src/tests/invariants.rs index 8ab78b57..593575d5 100644 --- a/packages/contracts/contracts/linkora-contracts/src/tests/invariants.rs +++ b/packages/contracts/contracts/linkora-contracts/src/tests/invariants.rs @@ -1,4 +1,5 @@ #![cfg(test)] +extern crate std; use crate::test::{ credential_authority_pubkey, credential_authority_signing_key, sign_credential_root, @@ -360,6 +361,49 @@ fn invariant_no_orphaned_authored_posts_after_profile_deletion() { assert!(client.get_post(&post_id2).is_none()); } +// ── Issue #1244: fee_bps upper-bound invariant ──────────────────────────────── + +#[test] +fn test_invariant_fee_bps_bounded_at_boundaries() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _) = setup_test_env(&env); + + // Invariant: 0 <= fee_bps <= 10_000 holds at both boundaries. + client.set_fee(&admin, &0); + assert_eq!(client.get_fee_bps(), 0); + + client.set_fee(&admin, &10_000); + assert_eq!(client.get_fee_bps(), 10_000); +} + +#[test] +fn test_invariant_fee_bps_never_exceeds_max() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _) = setup_test_env(&env); + + client.set_fee(&admin, &500); + assert_eq!(client.get_fee_bps(), 500); + + // An admin misconfiguration (fee_bps > 10_000, i.e. > 100%) must be + // rejected outright rather than clamped or stored, since fee computation + // (amount * fee_bps / 10_000) would otherwise exceed the transferred + // amount and cause tip/pool operations to revert or mint negative net + // value. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.set_fee(&admin, &20_000); + })); + assert!( + result.is_err(), + "fee_bps above 10_000 must panic, not clamp" + ); + + // Invariant: the rejected update must leave the previously stored, + // in-bounds fee untouched. + assert_eq!(client.get_fee_bps(), 500); +} + // ── Helpers ────────────────────────────────────────────────────────────────── fn setup_test_env(env: &Env) -> (LinkoraContractClient<'_>, Address, Address) { diff --git a/packages/sdk/src/__tests__/events-drift.test.ts b/packages/sdk/src/__tests__/events-drift.test.ts index 5aba2382..a8e092a1 100644 --- a/packages/sdk/src/__tests__/events-drift.test.ts +++ b/packages/sdk/src/__tests__/events-drift.test.ts @@ -1,6 +1,6 @@ -import { parseContractEvent } from "../events/types.js"; -import { RawLinkoraEvent } from "../generated/events.js"; -import { parseRawContractEvent } from "../generated/events.js"; +import { parseContractEvent as _parseContractEvent } from "../events/types.js"; +import { RawLinkoraEvent as _RawLinkoraEvent } from "../generated/events.js"; +import { parseRawContractEvent as _parseRawContractEvent } from "../generated/events.js"; describe("Event type drift", () => { it("dummy runtime check to satisfy jest", () => { diff --git a/packages/sdk/src/__tests__/health.test.ts b/packages/sdk/src/__tests__/health.test.ts index e6075561..85752194 100644 --- a/packages/sdk/src/__tests__/health.test.ts +++ b/packages/sdk/src/__tests__/health.test.ts @@ -325,43 +325,50 @@ describe("ConnectionHealthMonitor", () => { describe("jitter and backoff (Issue 1265)", () => { it("adds jitter to initial and subsequent checks", async () => { const setTimeoutSpy = jest.spyOn(global, "setTimeout"); - - const monitor = new ConnectionHealthMonitor("https://rpc.example.com", { intervalMs: 50, backoffMs: 20 }); + + const monitor = new ConnectionHealthMonitor("https://rpc.example.com", { + intervalMs: 50, + backoffMs: 20, + }); monitor.start(); - + expect(setTimeoutSpy).toHaveBeenCalled(); - const firstCallDelay = setTimeoutSpy.mock.calls[setTimeoutSpy.mock.calls.length - 1][1] as number; + const firstCallDelay = setTimeoutSpy.mock.calls[ + setTimeoutSpy.mock.calls.length - 1 + ][1] as number; expect(firstCallDelay).toBeGreaterThanOrEqual(0); expect(firstCallDelay).toBeLessThanOrEqual(20); // up to this.backoffMs - + monitor.stop(); setTimeoutSpy.mockRestore(); }); it("stops probing when max backoff is reached and can be resumed", async () => { - let callCount = 0; mockGetLatestLedger.mockImplementation(() => { return Promise.reject(new Error("down")); }); - const monitor = new ConnectionHealthMonitor("https://rpc.example.com", { intervalMs: 10, backoffMs: 10, maxBackoffMs: 10 }); + const monitor = new ConnectionHealthMonitor("https://rpc.example.com", { + intervalMs: 10, + backoffMs: 10, + maxBackoffMs: 10, + }); monitor.start(); - + // Wait for a few backoff cycles await new Promise((r) => setTimeout(r, 100)); - + const checksAfterStop = mockGetLatestLedger.mock.calls.length; - + // Wait another 100ms to ensure no further checks occur await new Promise((r) => setTimeout(r, 100)); expect(mockGetLatestLedger.mock.calls.length).toBe(checksAfterStop); - + // Manual resume should restart it monitor.resume(); await new Promise((r) => setTimeout(r, 100)); expect(mockGetLatestLedger.mock.calls.length).toBeGreaterThan(checksAfterStop); - + monitor.stop(); }); }); - -}); \ No newline at end of file +}); diff --git a/packages/sdk/src/__tests__/write.test.ts b/packages/sdk/src/__tests__/write.test.ts index 3d1ebf8a..7c9345f5 100644 --- a/packages/sdk/src/__tests__/write.test.ts +++ b/packages/sdk/src/__tests__/write.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ import { LinkoraClient } from "../client"; import { InvalidInputError, ValidationError } from "../errors"; @@ -32,7 +33,7 @@ jest.mock("@stellar/stellar-base", () => ({ })), scValToNative: jest.fn(), TransactionBuilder: jest.fn(() => ({ addOperation: mockAddOperation })), - Account: jest.fn(), + Account: jest.fn().mockImplementation((accountId: string) => ({ _accountId: accountId })), Keypair: { random: jest.fn(() => ({ publicKey: () => "GWRITEKEYXXXXXXXXXXXXXXXXXXXXXXXXXX" })) }, xdr: {}, })); @@ -271,9 +272,11 @@ describe("prepare*Tx methods (Submittable)", () => { const val = (v: unknown) => expect.objectContaining({ _val: v }); it("prepareCreatePostTx fetches sequence and uses prepareTransaction", async () => { - jest.spyOn(client as any, 'getAccountForTx').mockResolvedValue(new (require("@stellar/stellar-base").Account)("GAUTHOR", "100")); - jest.spyOn(client, 'prepareTransaction').mockResolvedValue({ - toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }) + jest + .spyOn(client as any, "getAccountForTx") + .mockResolvedValue(new (require("@stellar/stellar-base").Account)("GAUTHOR", "100")); + jest.spyOn(client, "prepareTransaction").mockResolvedValue({ + toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }), } as any); const result = await client.prepareCreatePostTx("GAUTHOR", "hello"); @@ -287,9 +290,11 @@ describe("prepare*Tx methods (Submittable)", () => { }); it("prepareFollowTx fetches sequence and uses prepareTransaction", async () => { - jest.spyOn(client as any, 'getAccountForTx').mockResolvedValue(new (require("@stellar/stellar-base").Account)("GA", "100")); - jest.spyOn(client, 'prepareTransaction').mockResolvedValue({ - toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }) + jest + .spyOn(client as any, "getAccountForTx") + .mockResolvedValue(new (require("@stellar/stellar-base").Account)("GA", "100")); + jest.spyOn(client, "prepareTransaction").mockResolvedValue({ + toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }), } as any); const result = await client.prepareFollowTx("GA", "GB"); @@ -302,4 +307,3 @@ describe("prepare*Tx methods (Submittable)", () => { ); }); }); - diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 4f7a3583..43f02aae 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -101,6 +101,12 @@ function scvString(value: string): xdr.ScVal { function scvU32(value: number): xdr.ScVal { return nativeToScVal(value, { type: "u32" }); } +function scvU64(value: number | bigint): xdr.ScVal { + return nativeToScVal(value, { type: "u64" }); +} +function scvSymbol(value: string): xdr.ScVal { + return nativeToScVal(value, { type: "symbol" }); +} function scvI128(value: number | bigint): xdr.ScVal { return nativeToScVal(value, { type: "i128" }); } @@ -1097,11 +1103,7 @@ export class LinkoraClient extends GeneratedLinkoraClient { * @param horizonUrl Optional Horizon URL to use. Defaults based on the network passphrase. * @returns The base64-encoded transaction envelope XDR ready for wallet signing. */ - async prepareCreatePostTx( - author: string, - content: string, - horizonUrl?: string - ): Promise { + async prepareCreatePostTx(author: string, content: string, horizonUrl?: string): Promise { ensureAddress(author, "author"); ensureNonEmptyString(content, "content"); const sourceAccount = await this.getAccountForTx(author, horizonUrl); @@ -1160,11 +1162,7 @@ export class LinkoraClient extends GeneratedLinkoraClient { * @param horizonUrl Optional Horizon URL to use. Defaults based on the network passphrase. * @returns The base64-encoded transaction envelope XDR ready for wallet signing. */ - async prepareFollowTx( - follower: string, - followee: string, - horizonUrl?: string - ): Promise { + async prepareFollowTx(follower: string, followee: string, horizonUrl?: string): Promise { ensureAddress(follower, "follower"); ensureAddress(followee, "followee"); const sourceAccount = await this.getAccountForTx(follower, horizonUrl); diff --git a/packages/sdk/src/events/types.ts b/packages/sdk/src/events/types.ts index 68cee8cd..fae6f210 100644 --- a/packages/sdk/src/events/types.ts +++ b/packages/sdk/src/events/types.ts @@ -39,45 +39,60 @@ export interface RentPaidEvent extends BaseLinkoraEvent { extended_to_ledger: number; } -export type ProfileSetEvent = BaseLinkoraEvent & Omit & { type: "profile_set" }; +export type ProfileSetEvent = BaseLinkoraEvent & + Omit & { type: "profile_set" }; export type FollowEvent = BaseLinkoraEvent & Omit & { type: "follow" }; -export type UnfollowEvent = BaseLinkoraEvent & Omit & { type: "unfollow" }; +export type UnfollowEvent = BaseLinkoraEvent & + Omit & { type: "unfollow" }; export type BlockEvent = BaseLinkoraEvent & Omit & { type: "block" }; export type UnblockEvent = BaseLinkoraEvent & Omit & { type: "unblock" }; -export type PostCreatedEvent = BaseLinkoraEvent & Omit & { type: "post_created" }; +export type PostCreatedEvent = BaseLinkoraEvent & + Omit & { type: "post_created" }; export type TipEvent = BaseLinkoraEvent & Omit & { type: "tip" }; -export type PoolDepositEvent = BaseLinkoraEvent & Omit & { type: "pool_deposit" }; +export type PoolDepositEvent = BaseLinkoraEvent & + Omit & { type: "pool_deposit" }; -export type PoolWithdrawEvent = BaseLinkoraEvent & Omit & { type: "pool_withdraw" }; +export type PoolWithdrawEvent = BaseLinkoraEvent & + Omit & { type: "pool_withdraw" }; -export type PoolCreatedEvent = BaseLinkoraEvent & Omit & { type: "pool_created" }; +export type PoolCreatedEvent = BaseLinkoraEvent & + Omit & { type: "pool_created" }; export type LikePostEvent = BaseLinkoraEvent & Omit & { type: "like" }; -export type ContractUpgradedEvent = BaseLinkoraEvent & Omit & { type: "contract_upgraded" }; +export type ContractUpgradedEvent = BaseLinkoraEvent & + Omit & { type: "contract_upgraded" }; -export type PostDeletedEvent = BaseLinkoraEvent & Omit & { type: "post_deleted" }; +export type PostDeletedEvent = BaseLinkoraEvent & + Omit & { type: "post_deleted" }; -export type ProposalCreatedEvent = BaseLinkoraEvent & Omit & { type: "proposal_created" }; +export type ProposalCreatedEvent = BaseLinkoraEvent & + Omit & { type: "proposal_created" }; -export type ProposalSignedEvent = BaseLinkoraEvent & Omit & { type: "proposal_signed" }; +export type ProposalSignedEvent = BaseLinkoraEvent & + Omit & { type: "proposal_signed" }; -export type ProposalExecutedEvent = BaseLinkoraEvent & Omit & { type: "proposal_executed" }; +export type ProposalExecutedEvent = BaseLinkoraEvent & + Omit & { type: "proposal_executed" }; -export type PoolAdminAddedEvent = BaseLinkoraEvent & Omit & { type: "pool_admin_added" }; +export type PoolAdminAddedEvent = BaseLinkoraEvent & + Omit & { type: "pool_admin_added" }; -export type PoolAdminRemovedEvent = BaseLinkoraEvent & Omit & { type: "pool_admin_removed" }; +export type PoolAdminRemovedEvent = BaseLinkoraEvent & + Omit & { type: "pool_admin_removed" }; -export type PoolThresholdUpdatedEvent = BaseLinkoraEvent & Omit & { type: "pool_threshold_updated" }; +export type PoolThresholdUpdatedEvent = BaseLinkoraEvent & + Omit & { type: "pool_threshold_updated" }; -export type DmKeyPublishedEvent = BaseLinkoraEvent & Omit & { type: "dm_key_published" }; +export type DmKeyPublishedEvent = BaseLinkoraEvent & + Omit & { type: "dm_key_published" }; export interface CredentialRootUpdatedEvent extends BaseLinkoraEvent { type: "credential_root_updated"; @@ -91,19 +106,25 @@ export interface CredentialVerifiedEvent extends BaseLinkoraEvent { nullifier: string; } -export type FeeUpdatedEvent = BaseLinkoraEvent & Omit & { type: "fee_updated" }; +export type FeeUpdatedEvent = BaseLinkoraEvent & + Omit & { type: "fee_updated" }; -export type TreasuryUpdatedEvent = BaseLinkoraEvent & Omit & { type: "treasury_updated" }; +export type TreasuryUpdatedEvent = BaseLinkoraEvent & + Omit & { type: "treasury_updated" }; -export type GovProposalCreatedEvent = BaseLinkoraEvent & Omit & { type: "gov_proposal_created" }; +export type GovProposalCreatedEvent = BaseLinkoraEvent & + Omit & { type: "gov_proposal_created" }; export type GovVoteEvent = BaseLinkoraEvent & Omit & { type: "gov_vote" }; -export type GovProposalExecutedEvent = BaseLinkoraEvent & Omit & { type: "gov_proposal_executed" }; +export type GovProposalExecutedEvent = BaseLinkoraEvent & + Omit & { type: "gov_proposal_executed" }; -export type GovProposalVetoedEvent = BaseLinkoraEvent & Omit & { type: "gov_proposal_vetoed" }; +export type GovProposalVetoedEvent = BaseLinkoraEvent & + Omit & { type: "gov_proposal_vetoed" }; -export type EmergencyBypassEvent = BaseLinkoraEvent & Omit & { type: "emergency_bypass" }; +export type EmergencyBypassEvent = BaseLinkoraEvent & + Omit & { type: "emergency_bypass" }; export interface AttestationVerifiedEvent extends BaseLinkoraEvent { type: "attestation_verified"; @@ -116,20 +137,20 @@ export interface AttestationVerifiedEvent extends BaseLinkoraEvent { export interface PostReportedEvent extends BaseLinkoraEvent { type: "post_reported"; - post_id: number; + post_id: bigint; reporter: string; stake_amount: bigint; } export interface PostRemovedByModerationEvent extends BaseLinkoraEvent { type: "post_removed_by_moderation"; - post_id: number; + post_id: bigint; reporter: string; } export interface ReportDismissedEvent extends BaseLinkoraEvent { type: "report_dismissed"; - post_id: number; + post_id: bigint; reporter: string; } @@ -443,7 +464,11 @@ export function parseContractEvent(raw: SorobanEvent): LinkoraEvent | null { meta: eventMeta, }; case "contract_upgraded": - return { type: eventType, new_wasm_hash: payload.new_wasm_hash as Uint8Array, meta: eventMeta }; + return { + type: eventType, + new_wasm_hash: payload.new_wasm_hash as Uint8Array, + meta: eventMeta, + }; case "proposal_created": return { type: eventType, diff --git a/packages/sdk/src/health.ts b/packages/sdk/src/health.ts index 4a5552f2..14fade9f 100644 --- a/packages/sdk/src/health.ts +++ b/packages/sdk/src/health.ts @@ -64,7 +64,7 @@ export class ConnectionHealthMonitor { private hasChecked = false; private retryMetrics: RetryMetrics = emptyRetryMetrics(); -private boundResume = () => this.resume(); + private boundResume = () => this.resume(); constructor(rpcUrl: string, config: HealthCheckConfig = {}) { this.rpcUrl = rpcUrl; @@ -117,7 +117,6 @@ private boundResume = () => this.resume(); } } - /** Alias for start(). Useful for resuming after a sustained outage stops polling. */ resume(): void { this.start(); @@ -160,7 +159,10 @@ private boundResume = () => this.resume(); } private scheduleCheck(delayMs: number): void { - const baseJitter = delayMs === 0 ? Math.random() * this.backoffMs : delayMs * 0.2 * Math.random(); + const baseJitter = + delayMs === 0 + ? Math.random() * Math.min(this.intervalMs, 100) + : delayMs * 0.2 * Math.random(); this.timer = setTimeout(() => this.runCheck(), delayMs + baseJitter); }