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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 38 additions & 14 deletions packages/contracts/contracts/linkora-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
42 changes: 41 additions & 1 deletion packages/contracts/contracts/linkora-contracts/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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());
}

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![cfg(test)]
extern crate std;

use crate::test::{
credential_authority_pubkey, credential_authority_signing_key, sign_credential_root,
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions packages/sdk/src/__tests__/events-drift.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
35 changes: 21 additions & 14 deletions packages/sdk/src/__tests__/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

});
});
20 changes: 12 additions & 8 deletions packages/sdk/src/__tests__/write.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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: {},
}));
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -302,4 +307,3 @@ describe("prepare*Tx methods (Submittable)", () => {
);
});
});

18 changes: 8 additions & 10 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down Expand Up @@ -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<string> {
async prepareCreatePostTx(author: string, content: string, horizonUrl?: string): Promise<string> {
ensureAddress(author, "author");
ensureNonEmptyString(content, "content");
const sourceAccount = await this.getAccountForTx(author, horizonUrl);
Expand Down Expand Up @@ -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<string> {
async prepareFollowTx(follower: string, followee: string, horizonUrl?: string): Promise<string> {
ensureAddress(follower, "follower");
ensureAddress(followee, "followee");
const sourceAccount = await this.getAccountForTx(follower, horizonUrl);
Expand Down
Loading
Loading