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 on-chain 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
│ │ ├── ledger-icrc.ts # Ledger helper functions
│ │ └── 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/satellite/ledger-icrc.ts: Helper functions for interacting with the ICRC ledger.
- 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 { Account } from "@dfinity/ledger-icrc/dist/candid/icrc_ledger";
import { Principal } from "@dfinity/principal";
import {
type AssertSetDoc,
defineAssert,
defineHook,
type OnSetDoc
} from "@junobuild/functions";
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: Account = {
owner: Principal.fromUint8Array(context.caller),
subaccount: []
};
// Check current account balance
await assertWalletBalance({
ledgerId,
fromAccount,
amount: requestAmount,
fee
});
// Update request status to processed (atomic with transfer)
setRequestProcessed({
key,
version,
data
});
// Transfer from wallet to satellite
const toAccount: Account = {
owner: id(),
subaccount: []
};
await transferIcpFromWallet({
ledgerId,
fromAccount,
toAccount,
amount: requestAmount,
fee
});
}
});
// src/satellite/services.ts
export const assertWalletBalance = async ({
ledgerId,
fromAccount,
amount,
fee
}: {
ledgerId: Principal;
fromAccount: Account;
amount: bigint;
fee: bigint | undefined;
}) => {
const balance = await icrcBalanceOf({
ledgerId,
account: fromAccount
});
const total = amount + (fee ?? IC_TRANSACTION_FEE_ICP);
if (balance < total) {
throw new Error(
`Balance ${balance} is smaller than ${total} for account ${fromAccount.owner.toText()}.`
);
}
};
export const transferIcpFromWallet = async (params: {
ledgerId: Principal;
fromAccount: Account;
toAccount: Account;
amount: bigint;
fee: bigint | undefined;
}): Promise<bigint> => {
const result = await icrcTransferFrom(params);
if ("Err" in result) {
throw new Error(
`Failed to transfer ICP from wallet: ${JSON.stringify(result)}`
);
}
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 therequest
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
- 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 dev start
4. Create a Satellite for local dev:
- Visit http://localhost:5866 and follow the instructions.
- Update
juno.config.ts
with your Satellite ID.
- Create required collections:
request
in 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
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 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:
assertSetDoc
to validate requestsonSetDoc
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.