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_frommethod - Mark the request as
processedif 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
onSetDochook is triggered for therequestcollection. - 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
- Clone the repo:
git clone https://github.com/junobuild/examples
cd typescript/calls
2. Install dependencies:
npm install
3. Start Juno local emulator:
Requires the Juno CLI to be available npm i -g @junobuild/cli
juno emulator start
4. Create a Satellite for local dev:
- Visit http://localhost:5866 and follow the instructions.
- Update
juno.config.tswith your Satellite ID.
- Create required collections:
requestin Datastore: http://localhost:5866/datastore
- Start the frontend dev server (in a separate terminal):
npm run dev
- 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
junoplugin 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.tswith 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:
- GitHub: github.com/peterpeterparker/cycles.watch
- Example logic: src/satellite/index.ts
This app uses:
assertSetDocto validate requestsonSetDocto 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.