Motoko compiler keeps failing with “unexpected token ‘;’ / ‘stable’ even after clean rebuild (dfx 0.30.2)

Hi everyone,
I’m stuck on a Motoko compile error that I’ve not been able to resolve after multiple clean rebuilds, and I’d appreciate guidance from someone more experienced with Motoko syntax and compiler behavior.

Environment

  • OS: Windows (WSL Ubuntu)

  • dfx version: 0.30.2

  • Network: local replica

  • Project type: Motoko backend + JS/HTML frontend

  • Command used:

dfx stop
dfx start --clean
dfx deploy


The Error I Keep Getting

Every deploy fails with variations of this error:

syntax error [M0001], unexpected token ';'
syntax error [M0001], unexpected token 'stable'

Example:

/home/.../main.mo:15.3-15.9: syntax error, unexpected token 'stable'

or

/home/.../main.mo:22.59-22.60: syntax error, unexpected token ';'


What I’ve Tried

  • Removed semicolons from variable declarations

  • Ran dfx start --clean

  • Deleted .dfx directory

  • Reduced the file to minimal examples (which do compile)

  • Re-added code gradually, but the error returns once stable variables or upgrade hooks are involved


Current main.mo (backend)

import Debug "mo:base/Debug";
import Time "mo:base/Time";
import Principal "mo:base/Principal";
import HashMap "mo:base/HashMap";
import Iter "mo:base/Iter";
import List "mo:base/List";
import Array "mo:base/Array";

actor DBank {

  /* -----------------------------
     STABLE STORAGE
  -------------------------------- */
  stable var balancesStable : [(Principal, Int)] = []
  stable var timestampsStable : [(Principal, Int)] = []
  stable var platformEarningsStable : Int = 0
  stable var transactionLogsStable : [(Principal, Text)] = []

  /* -----------------------------
     TRANSIENT STORAGE
  -------------------------------- */
  transient var balances : HashMap.HashMap<Principal, Int>
  transient var lastUpdated : HashMap.HashMap<Principal, Int>
  transient var platformEarnings : Int
  transient var transactionLogs : [(Principal, Text)]


  /* -----------------------------
     CONFIG
  -------------------------------- */
  let INTEREST_BP : Int = 500;
  let PLATFORM_FEE_BP : Int = 1000;
  let BP_DIVISOR : Int = 10_000;
  let YEAR_NS : Int = 31_536_000_000_000_000;

  /* -----------------------------
     INIT
  -------------------------------- */
  {
    balances := HashMap.fromIter(
      balancesStable.vals(),
      10,
      Principal.equal,
      Principal.hash
    );

    lastUpdated := HashMap.fromIter(
      timestampsStable.vals(),
      10,
      Principal.equal,
      Principal.hash
    );

    platformEarnings := platformEarningsStable;
    transactionLogs := transactionLogsStable;
  }

  /* -----------------------------
     UPGRADE HOOKS
  -------------------------------- */
  system func preupgrade() {
    balancesStable := Iter.toArray(balances.entries());
    timestampsStable := Iter.toArray(lastUpdated.entries());
    platformEarningsStable := platformEarnings;
    transactionLogsStable := transactionLogs;
  };

  system func postupgrade() {
    balances := HashMap.fromIter(
      balancesStable.vals(),
      10,
      Principal.equal,
      Principal.hash
    );

    lastUpdated := HashMap.fromIter(
      timestampsStable.vals(),
      10,
      Principal.equal,
      Principal.hash
    );

    platformEarnings := platformEarningsStable;
    transactionLogs := transactionLogsStable;
  };

  /* -----------------------------
     INTEREST
  -------------------------------- */
  func applyInterest(user : Principal) {
    let now = Time.now();

    let lastTime = switch (lastUpdated.get(user)) {
      case (?t) t;
      case null now;
    };

    let elapsed = now - lastTime;
    if (elapsed <= 0) return;

    let balance = switch (balances.get(user)) {
      case (?b) b;
      case null 0;
    };

    let rawInterest =
      ((balance * INTEREST_BP * elapsed) / YEAR_NS) / BP_DIVISOR;

    if (rawInterest <= 0) {
      lastUpdated.put(user, now);
      return;
    };

    let fee = (rawInterest * PLATFORM_FEE_BP) / BP_DIVISOR;
    let userInterest = rawInterest - fee;

    balances.put(user, balance + userInterest);
    platformEarnings += fee;
    lastUpdated.put(user, now);
  };

  /* -----------------------------
     TOP UP
  -------------------------------- */
  public func topUp(user : Principal, amount : Int)
    : async { success : Bool; message : Text; balance : Int } {

    if (amount <= 0) {
      return {
        success = false,
        message = "Top-up amount must be greater than 0",
        balance = switch (balances.get(user)) {
          case (?b) b;
          case null 0;
        }
      };
    };

    applyInterest(user);

    let current = switch (balances.get(user)) {
      case (?b) b;
      case null 0;
    };

    let newBalance = current + amount;
    balances.put(user, newBalance);
    lastUpdated.put(user, Time.now());

    let log = "Top-up: $" # debug_show(amount);
    transactionLogs := Array.append(transactionLogs, [(user, log)]);
    Debug.print(log);

    return {
      success = true,
      message = "You have successfully topped up $" # debug_show(amount),
      balance = newBalance
    };
  };

  /* -----------------------------
     WITHDRAW
  -------------------------------- */
  public func withdraw(user : Principal, amount : Int)
    : async { success : Bool; message : Text; balance : Int } {

    if (amount <= 0) {
      return {
        success = false,
        message = "Withdrawal amount must be greater than 0",
        balance = switch (balances.get(user)) {
          case (?b) b;
          case null 0;
        }
      };
    };

    applyInterest(user);

    let current = switch (balances.get(user)) {
      case (?b) b;
      case null 0;
    };

    if (amount > current) {
      let log = "Withdrawal failed: insufficient funds";
      transactionLogs := Array.append(transactionLogs, [(user, log)]);
      Debug.print(log);

      return {
        success = false,
        message = "Insufficient balance",
        balance = current
      };
    };

    let newBalance = current - amount;
    balances.put(user, newBalance);
    lastUpdated.put(user, Time.now());

    let log = "Withdrawal: $" # debug_show(amount);
    transactionLogs := Array.append(transactionLogs, [(user, log)]);
    Debug.print(log);

    return {
      success = true,
      message = "You have successfully withdrawn $" # debug_show(amount),
      balance = newBalance
    };
  };

  /* -----------------------------
     READ
  -------------------------------- */
  public query func getBalance(user : Principal) : async Int {
    switch (balances.get(user)) {
      case (?b) b;
      case null 0;
    };
  };

  public query func getTransactionLogs(user : Principal) : async [Text] {
    transactionLogs
      |> List.fromArray
      |> List.filter(func (e) { e.0 == user })
      |> List.map(func (e) { e.1 })
      |> List.toArray;
  };
}


My Questions

  1. Are stable + transient variables inside the same actor still valid in Motoko 0.30.x?

  2. Is the anonymous actor init block { ... } still correct syntax?

  3. Are there breaking syntax changes I’m missing compared to older Motoko examples?

  4. Is there a recommended minimal pattern for stable + HashMap + upgrade hooks that definitely compiles today?

At this point I’m not trying to add features I just want a correct, compiling Motoko baseline that follows best practices.

Thanks in advance :folded_hands:
Any help or reference example would be massively appreciated.

Hello, do I understand correctly that you had older code that used to compile but now it doesn’t?

What you could do is just keep old code and add the “–legacy-persistence” compiler flag; In your dfx.json file, you can add a field of this kind to the canister object:
"args": "--legacy-persistence"

On the other hand, if you want to (re-)build your app from scratch, we can walk you through how to use the new enhanced orthogonal persistence feature and the core library instead of the old base.

hope this helps,
Alex

1 Like

Hi Alex, thanks for the explanation, that helps a lot.

Yes, that’s correct, the code used to compile before and I haven’t intentionally changed the persistence model. It sounds like the issue is coming from newer Motoko versions enforcing the enhanced orthogonal persistence rules.

For now, I’d prefer to keep the existing code working rather than rebuild everything from scratch. I’ll try adding the --legacy-persistence flag in dfx.json as you suggested.

Once I confirm that works, I’d be interested in understanding the recommended path for migrating to the new persistence model in the future.

Thanks again for the guidance.

First thing you have to do is:

persistent actor DBank {

The persistence keyword is mandatory now.

Then remove all the stable keywords. They are implicit because the actor is declared persistent.

Then put semicolons at the end of each line. They are missing currently.

Then your “transient storage” block has is missing the initial values. The lines starting with transient var need a = <some value>; at the end.

And as said in the previous post, at this stage you should right away switch to using core, not base.

1 Like

Hello Timo/Alex

Thanks for the guidance so far. I’ve dug a bit deeper, and I think the compile errors I’m seeing (unexpected token 'stable' / ';') are due to changes in Motoko 0.30.x. Here’s what I’ve learned:

  1. Persistent actors are now required for stable variables

    • actor DBank { stable var ... } no longer compiles. It must be:

      persistent actor DBank {
          stable var balancesStable : [(Principal, Int)] = [];
          ...
      }
      
      
  2. Transient keyword is obsolete

    • I replaced transient var with plain var inside the actor. The compiler now infers that they’re non-persistent.
  3. Init block syntax changed slightly

    • Using { ... } alone can cause issues. The recommended pattern is:

      init {
          // initialize transient vars from stable
      }
      
      
  4. Upgrade hooks remain valid, but semicolons after function blocks cause the unexpected token ';' error.

I’ve tested a minimal baseline with persistent actor, stable var, init, and preupgrade/postupgrade hooks, and it compiles cleanly in 0.30.2.

I plan to refactor my full DBank actor with this pattern. If you want, I can share the updated full version so others using 0.30.x don’t hit the same issues.