Smart contract behaves differently in local test and testnet environment

I have a smart contract that basically exposes two entry point. A stake and an unstake function.

In the local test, when a user deposits a token; it executes successfully and writes to the contract state but when I call the unstake function it executes without an Invoke error but state changes are not persisted after the contract call.

On the testnet, when I call the stake function, it successfully executes but no state changes are persisted. when I query the smart contract to view the stake it returns none and when I try to use the unstake function it returns a stake is not found error.

This is the stake function:

#[receive(
    contract = "gona_stake",
    name = "stake",
    error = "StakingError",
    parameter = "OnReceivingCis2DataParams<ContractTokenId,ContractTokenAmount,PublicKeyEd25519>",
    enable_logger,
    mutable
)]
fn stake(ctx: &ReceiveContext, host: &mut Host<State>, logger: &mut Logger) -> ReceiveResult<()> {
    let parameter: OnReceivingCis2DataParams<
        ContractTokenId,
        ContractTokenAmount,
        PublicKeyEd25519,
    > = ctx.parameter_cursor().get()?;

    let amount = parameter.amount;
    let token_id = parameter.token_id;
    let state = host.state_mut();
    let gona_token = state.token_address;

    let staker = parameter.data;

    // Ensures that only contracts can call this hook function.
    let sender_contract_address = match ctx.sender() {
        Address::Contract(sender_contract_address) => sender_contract_address,
        Address::Account(_) => bail!(StakingError::OnlyContractCanStake.into()),
    };
    // Cannot stake less than 0.001 of our token
    ensure!(
        amount.0.ge(&1000),
        StakingError::CannotStakeLessThanAllowAmount.into()
    );
    ensure_eq!(
        sender_contract_address,
        gona_token,
        StakingError::SenderContractAddressIsNotAllowedToStake.into()
    );
    let mut entry = StakeEntry {
        amount,
        time_of_stake: ctx.metadata().slot_time(),
        token_id,
    };
    if let Some(stake_entry) = state.stake_entries.remove_and_get(&staker) {
        let days_of_stake = ctx
            .metadata()
            .slot_time()
            .duration_since(stake_entry.time_of_stake)
            .ok_or(StakingError::DaysOfStakeCouldNotBeCalculated)?
            .days();
        let previous_amount = stake_entry.amount;
        let rewards = calculate_percent(previous_amount.0, state.weight, state.decimals);
        if days_of_stake > 0 {
            let rewards = rewards * days_of_stake;
            let new_amount = previous_amount + TokenAmountU64(rewards);
            entry.amount = new_amount;
        } else {
            entry.amount += previous_amount;
        }
        stake_entry.delete();
        state.stake_entries.entry(staker).or_insert(entry);
    } else {
        state.stake_entries.entry(staker).or_insert(entry);
    }
    logger.log(&StakingEvent::Staked {
        staker,
        amount,
        time: ctx.metadata().slot_time(),
    })?;

    Ok(())
}

and this is the unstake function:


#[receive(
    contract = "gona_stake",
    name = "unstake",
    error = "StakingError",
    parameter = "UnstakeParam",
    enable_logger,
    mutable
)]
fn unstake(ctx: &ReceiveContext, host: &mut Host<State>, logger: &mut Logger) -> ReceiveResult<()> {
    let param: UnstakeParam = ctx.parameter_cursor().get()?;
    let state = host.state_mut();

    let weight = state.weight;
    let decimals = state.decimals;
    let reward_volume = state.reward_volume;

    let stake_entry = state
        .stake_entries
        .get(&param.staker)
        .ok_or(StakingError::StakingNotFound)?;

    let previous_amount = stake_entry.amount;
    ensure!(
        previous_amount.0.ge(&param.amount.0),
        StakingError::InsufficientFunds.into()
    );
    let days_of_stake = ctx
        .metadata()
        .slot_time()
        .duration_since(stake_entry.time_of_stake)
        .ok_or(StakingError::DaysOfStakeCouldNotBeCalculated)?
        .days();

    let mut amount = param.amount;
    let token_address = state.token_address;
    let smart_wallet = state.smart_wallet;

    // if days == 0 and you calculate reward. it will change balance to 0
    if days_of_stake > 0 {
        let rewards = calculate_percent(amount.0, weight, decimals);
        let cumulative_rewards = rewards * days_of_stake;
        ensure!(reward_volume >= rewards, StakingError::Overflow.into());
        state.reward_volume -= cumulative_rewards;
        amount += TokenAmountU64(cumulative_rewards);
        //bail!(StakingError::TransferError.into());
    }

    let owned_entry = OwnedEntrypointName::new_unchecked("depositCis2Tokens".into());
    // Create a Transfer instance
    let transfer_payload = Transfer {
        token_id: TOKEN_ID,
        amount,
        to: Receiver::Contract(smart_wallet, owned_entry),
        from: Address::Contract(ctx.self_address()),
        data: AdditionalData::from(to_bytes(&param.staker)),
    };
    let entry_point = EntrypointName::new_unchecked("transfer".into());

    let mut transfers = Vec::new();
    transfers.push(transfer_payload);
    let payload = TransferParams::from(transfers);
    // calculate transfer after withdrawal; if amount is less than 0.001 flush the account
    let balance = previous_amount.0 - param.amount.0;

    if balance < 1000 {
        state.stake_entries.remove(&param.staker);
    } else {
        state
            .stake_entries
            .entry(param.staker.clone())
            .and_modify(|stake| {
                stake.amount = TokenAmountU64(balance);
            });
    }

    host.invoke_contract(&token_address, &payload, entry_point, Amount::zero())?;

    logger.log(&StakingEvent::Unstaking {
        amount: param.amount,
        staker: param.staker,
        time: ctx.metadata().slot_time(),
    })?;
    Ok(())
}

This is my state:

#[concordium(state_parameter = "S")]
pub struct State<S = StateApi> {
    pub stake_entries: StateMap<PublicKeyEd25519, StakeEntry, S>,
    pub decimals: u8,
    pub token_address: ContractAddress,
    pub weight: u32,
    pub paused: bool,
    pub admin: Address,
    pub smart_wallet: ContractAddress,
    pub reward_volume: u64,
}

This is the stake entry param:

#[derive(Serialize, SchemaType, PartialEq, Eq, Clone, Debug)]
pub struct StakeEntry {
    pub amount: TokenAmountU64,
    pub time_of_stake: Timestamp,
    pub token_id: TokenIdUnit,
}

Could you please share the public repo including the tests?

here it is: GitHub - BukiOffor/simple-gona-stake

Dear Buki,
Could you create a minimal POC? It seems we are having a hard time understanding/replicating the exact problem that you asked us to look at. Is there a smart contract with a transaction that you could point us to? Thank you.

Yes, this is a minimal abstract from the original code base. if you take a look at https://ccdexplorer.io/testnet/instance/10341/0 . (stake contract). you will see that the stake entry point has executed successfully atleast 2 times and hold the number of tokens that was staked. But the contract state does not hold any of these information. e.g if I query the contract with the public key that performed the stake, it returns none. It shouldn’t be none because the state was updated during the stake invocation.

But the unit test works fine with the same wasm contract. during the unit test, the states are updated and deterministic behaviour are verified. But on the testnet, the contract call is succesful without any state change.