Skip to main content

Making Canister Calls in TypeScript Serverless Functions

This example demonstrates how to use TypeScript serverless functions to perform canister calls (such as transfer_from on the ICP ledger) in response to Datastore events in your Juno Satellite.

When a document is added to the request collection, a serverless function is triggered to:

  • Check if the user has enough ICP in their wallet
  • Transfer ICP from the user's wallet to the Satellite using the ICRC ledger's transfer_from method
  • Mark the request as processed if the transfer succeeds

This pattern is useful for building workflows that require asset transfers or other canister calls in response to user actions.

You can browse the source code here: github.com/junobuild/examples/tree/main/functions/typescript/calls


Folder Structure

typescript/calls/
├── src/
│ ├── satellite/ # TypeScript Satellite serverless function
│ │ ├── index.ts # Main TypeScript logic for Satellite
│ │ ├── services.ts # Helper logic for balance, transfer, status
│ │ └── tsconfig.json # TypeScript config for Satellite
│ ├── declarations/
│ │ └── satellite/ # TypeScript declarations for Satellite
│ ├── components/ # React frontend components
│ ├── services/ # Frontend service logic
│ ├── types/ # Frontend type definitions
│ ├── main.tsx # Frontend entry
│ └── ...
├── juno.config.ts # Juno Satellite configuration
├── package.json # Frontend dependencies
└── ...

Key Features

  • Serverless Canister Calls: Demonstrates how to perform ICRC ledger calls (e.g., transfer_from) from TypeScript serverless functions.
  • Atomic Request Processing: Ensures that request status is only updated if the transfer succeeds.
  • Wallet Balance Checks: Fails early if the user does not have enough ICP.
  • Minimal React UI: A simple React frontend is included to test and demonstrate the logic.

Main Backend Components

  • src/satellite/index.ts: The entry point for the Satellite serverless function. Triggers the canister call and updates request status on document set.
  • src/satellite/services.ts: Helper logic for checking wallet balance, performing the transfer, and updating request status.
  • src/types/request.ts: Data model for requests and status.

Example: Canister Call on Document Set

Here’s the actual TypeScript logic from index.ts and services.ts:

// src/satellite/index.ts
import { Principal } from "@icp-sdk/core/principal";
import {
type AssertSetDoc,
defineAssert,
defineHook,
type OnSetDoc
} from "@junobuild/functions";
import { IcrcLedgerDid } from "@junobuild/functions/canisters/ledger/icrc";
import { id } from "@junobuild/functions/ic-cdk";
import { decodeDocData } from "@junobuild/functions/sdk";
import { COLLECTION_REQUEST, ICP_LEDGER_ID } from "../constants/app.constants";
import { RequestData, RequestDataSchema } from "../types/request";
import {
assertWalletBalance,
setRequestProcessed,
transferIcpFromWallet
} from "./services";

export const assertSetDoc = defineAssert<AssertSetDoc>({
collections: [COLLECTION_REQUEST],
assert: (context) => {
// We validate that the data submitted for create or update matches the expected schema.
const person = decodeDocData<RequestData>(context.data.data.proposed.data);

RequestDataSchema.parse(person);
}
});

export const onSetDoc = defineHook<OnSetDoc>({
collections: [COLLECTION_REQUEST],
run: async (context) => {
// ###############
// Init data
// ###############

const {
data: {
key,
data: {
after: { version }
}
}
} = context;

const data = decodeDocData<RequestData>(context.data.data.after.data);

const { amount: requestAmount, fee } = data;

const ledgerId = ICP_LEDGER_ID;

const fromAccount: IcrcLedgerDid.Account = {
owner: Principal.fromUint8Array(context.caller),
subaccount: []
};

// ###############
// Check current account balance. This way the process can stop early on
// ###############
await assertWalletBalance({
ledgerId,
fromAccount,
amount: requestAmount,
fee
});

// ###############
// The request is about to be processed by transferring the amount via the ICP ledger.
// We update the status beforehand. Since the function is atomic, a failed transfer reverts everything.
// This avoids a case where the transfer succeeds but the status isn't updated — even if unlikely.
// This is for demo only. In production, proper error handling and bookkeeping would be required.
// ###############

setRequestProcessed({
key,
version,
data
});

// ###############
// Transfer from wallet to satellite.
// ###############

const toAccount: IcrcLedgerDid.Account = {
owner: id(),
subaccount: []
};

await transferIcpFromWallet({
ledgerId,
fromAccount,
toAccount,
amount: requestAmount,
fee
});

console.log(`${requestAmount} ICP transferred to Satellite 🥳`);
}
});
// src/satellite/services.ts
export const assertWalletBalance = async ({
ledgerId,
fromAccount,
amount,
fee
}: {
ledgerId: Principal;
fromAccount: IcrcLedgerDid.Account;
amount: bigint;
fee: bigint | undefined;
}) => {
const { icrc1BalanceOf } = new IcrcLedgerCanister({ canisterId: ledgerId });

const balance = await icrc1BalanceOf({
account: fromAccount
});

const total = amount + (fee ?? IC_TRANSACTION_FEE_ICP);

if (balance < total) {
const encodedAccountText = encodeIcrcAccount({
owner: fromAccount.owner,
subaccount: fromNullable(fromAccount.subaccount)
});

throw new Error(
`Balance ${balance} is smaller than ${total} for account ${encodedAccountText}.`
);
}
};

export const transferIcpFromWallet = async ({
ledgerId,
fromAccount,
amount,
fee,
toAccount
}: {
ledgerId: Principal;
fromAccount: IcrcLedgerDid.Account;
toAccount: IcrcLedgerDid.Account;
amount: bigint;
fee: bigint | undefined;
}): Promise<IcrcLedgerDid.Tokens> => {
const args: IcrcLedgerDid.TransferFromArgs = {
amount,
from: fromAccount,
to: toAccount,
created_at_time: toNullable(),
fee: toNullable(fee),
memo: toNullable(),
spender_subaccount: toNullable()
};

const { icrc2TransferFrom } = new IcrcLedgerCanister({
canisterId: ledgerId
});

const result = await icrc2TransferFrom({ args });

if ("Err" in result) {
throw new Error(
`Failed to transfer ICP from wallet: ${JSON.stringify(result, jsonReplacer)}`
);
}

return result.Ok;
};

export const setRequestProcessed = ({
key,
data: currentData,
version: originalVersion
}: {
key: string;
data: RequestData;
version: bigint | undefined;
}) => {
const updateData: RequestData = {
...currentData,
status: "processed"
};

const data = encodeDocData(updateData);

const doc: SetDoc = {
data,
version: originalVersion
};

setDocStore({
caller: id(),
collection: COLLECTION_REQUEST,
doc,
key
});
};

Explanation:

  • When a request is submitted, the onSetDoc hook is triggered for the request collection.
  • The function checks the user's wallet balance, updates the request status, and performs the ICP transfer atomically.
  • If any step fails, the entire operation is reverted.
  • The frontend can monitor request status and balances via the exposed APIs.

How to Run

  1. Clone the repo:
git clone https://github.com/junobuild/examples
cd typescript/calls

2. Install dependencies:

npm install

3. Start Juno local emulator:

important

Requires the Juno CLI to be available npm i -g @junobuild/cli

juno emulator start

4. Create a Satellite for local dev:

  1. Create required collections:
  1. Start the frontend dev server (in a separate terminal):
npm run dev
  1. Build the serverless functions (in a separate terminal):
juno functions build

The emulator will automatically upgrade your Satellite and live reload the changes.


Juno-Specific Configuration

  • juno.config.ts: Defines Satellite IDs for development/production, build source, and predeploy steps. See the Configuration reference for details.
  • vite.config.ts: Registers the juno plugin to inject environment variables automatically. See the Vite Plugin reference for more information.

Production Deployment

  • Create a Satellite on the Juno Console for mainnet.
  • Update juno.config.ts with the production Satellite ID.
  • Build and deploy the frontend:
npm run build
juno hosting deploy
  • Build and upgrade the serverless functions:
juno functions build
juno functions upgrade

Notes

  • This example focuses on the TypeScript serverless function. The frontend is intentionally minimal and included only for demonstration.
  • Use this project as a starting point for writing custom backend logic in TypeScript using Juno serverless functions and canister calls.

Real-World Example

Want to see how assertions and serverless logic are used in a live project?

Check out cycles.watch, an open-source app built with Juno:

This app uses:

  • assertSetDoc to validate requests
  • onSetDoc to implement a swap-like feature that performs various canister calls
  • Service modules to keep logic organized
  • A real-world pattern for chaining calls and document insertions with assertions

It’s a great reference for more advanced setups and orchestration.


References