Loop SDK allows dApps connect to a Loop account. The Loop wallet can be on mobile or on a desktop browser. All the interaction will happen inside the dApp. For signing, user will be prompted to sign either on their Loop wallet on mobile devices or on browser. If you need a custom DAR, read this guide for integration steps.
For a quick overview of how the code look like, you can take a look at this pen https://codepen.io/kureikain/pen/KwVGgLX.
To use the Loop SDK, you first need to install it from NPM:
bun add @fivenorth/loop-sdkThen you can import it in your dApp:
import { loop } from '@fivenorth/loop-sdk';Note that, If you don't want to implement a build process, you can include the file directly with unpkg such as
import { loop } from 'https://unpkg.com/@fivenorth/loop-sdk@0.14.0/dist';An example of how we use it in that manner is on our loopsdk demo
Before you can connect, you need to initialize the SDK. This is typically done once when your application loads.
loop.init({
appName: 'My Awesome dApp',
network: 'local', // or 'devnet', 'mainnet'
onTransactionUpdate: (payload) => {
console.log('Transaction update:', payload);
},
options: {
openMode: 'popup', // 'popup' (default) or 'tab'
requestSigningMode: 'popup', // 'popup' (default) or 'tab'
redirectUrl: 'https://myapp.com/after-connect', // optional redirect after approval
},
onAccept: (provider) => {
console.log('Connected!', provider);
// You can now use the provider to interact with the wallet
},
onReject: () => {
console.log('Connection rejected by user.');
},
});The init method takes a configuration object with the following properties:
appName: The name of your application, which will be displayed to the user in the Loop wallet.network: The network to connect to. Can belocal,devnet, ormainnet.onTransactionUpdate: Called when a transaction update is finalized (includesupdate_idand optionalupdate_data).options: Optional object containing:openMode: Controls how Loop opens:'popup'(default) or'tab'.requestSigningMode: Controls how signing/transaction requests open the wallet UI after you're connected:'popup'(default) or'tab'.redirectUrl: Optional redirect URL the wallet will navigate back to after successful approval. If omitted, user stays on Loop dashboard.
onAccept: A callback function that is called when the user accepts the connection. It receives aproviderobject.onReject: A callback function that is called when the user rejects the connection.
To initiate the connection, call loop.connect():
loop.connect();This will open a modal with a QR code for the user to scan with their Loop wallet.
If you set requestSigningMode to 'popup' (or 'tab'), each signing/transaction request will also open the wallet dashboard. The SDK does not auto-close the popup/tab; wallet UI controls completion/close behavior.
Once the connection is established, the onAccept callback will receive a provider object. This object provides methods to interact with the user's wallet and the DAML ledger.
The provider object has the party_id of the connected user.
Retrieve the latest account status for the connected wallet:
const account = await provider.getAccount();
console.log(account.has_preapproval);
console.log(account.utility_preapproval_admins);
console.log(account.has_merge_delegation);
console.log(account.usdc_bridge_access);To get the user's token holdings:
const holdings = await provider.getHolding();
console.log(holdings);Each holding includes its instrument_id (with admin and id fields), which you can use when building transfers for CC, CIP-56 tokens, LOOP, or any custom instrument.
You can query for active contracts by templateId or interfaceId.
By Template ID:
const contracts = await provider.getActiveContracts({
templateId: '#splice-amulet:Splice.Amulet:Amulet'
});
console.log(contracts);By Interface ID:
const contracts = await provider.getActiveContracts({
interfaceId: '#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding'
});
console.log(contracts);const gasEstimate = await provider.estimateGas(damlCommand);
console.log(gasEstimate);provider.estimateGas(...) is the existing browser / WalletConnect estimate helper. It keeps the existing method name, but returns the expected network fee before submission.
To submit a DAML transaction, you need to construct a command object and pass it to submitTransaction:
const damlCommand = {
commands: [{
ExerciseCommand: {
templateId: "#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory",
contractId: 'your-contract-id', // The contract ID to exercise the choice on
choice: 'TransferFactory_Transfer',
choiceArgument: {
// ... your choice arguments
}
}
}],
// ... other command properties
};
try {
const result = await provider.submitTransaction(damlCommand, {
// Optional: show a custom message in the wallet prompt
message: 'Transfer 10 CC to RetailStore',
estimateTraffic: true, // optional: return estimated traffic in submission response
deduplicationPeriod: { seconds: 60 }, // optional: override the default 30 minute dedup window
});
console.log('Transaction successful:', result);
} catch (error) {
console.error('Transaction failed:', error);
}onTransactionUpdate fires once per transaction with a single payload that includes command_id and submission_id. On success it also includes update_id and update_data (ledger transaction tree); on failure it includes status: "failed" and error.error_message.
submitTransaction is the default async path. It returns the submission result first (including command_id and submission_id), then the ledger update arrives later via onTransactionUpdate with update_id and update_data.
To wait for the transaction result directly (opt-in), use:
await provider.submitAndWaitForTransaction(damlCommand, {
message: 'Transfer 10 CC to RetailStore',
deduplicationPeriod: { seconds: 60 },
});In wait mode, the final result is returned as a single onTransactionUpdate payload (command/submission IDs plus update data or failure status).
Note: submitAndWaitForTransaction errors do not always mean the transaction failed. A 4xx error (e.g., 400) indicates a definite failure. A 5xx/timeout can mean the ledger is slow or backed up; the transaction may still be committed later, so clients should continue to listen for updates rather than assume failure.
Deduplication: by default the wallet execute path uses a 30 minute deduplication window. You can override it with deduplicationPeriod in submit options. For ambiguous outcomes (for example timeout, disconnect, or 5xx where the previous submission may already have reached Canton), retry with the same payload commandId within that window to avoid double execution.
You can request the user to sign an arbitrary message:
const message = 'Hello, Loop!';
try {
const signature = await provider.signMessage(message);
console.log('Signature:', signature);
} catch (error) {
console.error('Signing failed:', error);
}await loop.wallet.transfer(
'receiver::fingerprint',
'5',
{
instrument_admin: 'issuer::fingerprint', // optional: DSO (default)
instrument_id: 'Amulet', // optional: Amulet (default)
},
{
message: 'Send 5 CC to Alice', // optional: show a custom message in the wallet prompt
memo: 'optional memo for the transfer', // optional: stored as transfer metadata
executionMode: 'wait', // optional: 'async' (default) or 'wait'
requestedAt: new Date().toISOString(), // optional
executeBefore: new Date(Date.now() + 24*60*60*1000).toISOString(), // optional
requestTimeout: 5 * 60 * 1000, // optional (ms), defaults to 5 minutes
estimateTraffic: true, // optional: return estimated traffic in submission response
deduplicationPeriod: { seconds: 60 }, // optional: override the default 30 minute dedup window
},
);Notes:
- You must have spendable holdings for the specified instrument (admin + id). If left blank, the SDK defaults to the native token.
- The helper handles fetching holdings, building the transfer factory payload, and submitting via Wallet Connect.
- If the wallet popup/tab is closed before the request completes, the SDK rejects with
PopupClosedError. If no response arrives and no closable window handle is available, the request still falls back torequestTimeout.
Common instrument overrides (pass into the instrument argument above):
- Canton Coin (CC):
{ instrument_admin: 'cc-issuer::fingerprint', instrument_id: 'CC' } - CIP-56:
{ instrument_admin: 'cip56-issuer::fingerprint', instrument_id: 'CIP-56' }
Swap in the admin/id for the specific instrument you hold in the Loop wallet.
await loop.wallet.extension.usdcBridge.withdrawalUSDCxToEthereum(
'0xYourEthAddress',
'10.5', // amount in USDCx
{
reference: 'optional memo',
message: 'Withdraw 10.5 USDCx to 0xabc', // optional custom prompt text
requestTimeout: 5 * 60 * 1000, // optional override (ms)
},
);Notes:
- Uses the connect-based withdraw endpoint to prepare the transaction and sends it over Wallet Connect.
- The helper auto-reconnects the websocket if it was closed before sending the request.
Coming soon
Loop SDK also supports a server-side signing flow. Instead of a wallet popup, your backend signs and submits transactions directly using the user's private key.
For server-side usage, you need to install the SDK and node-forge:
bun add @fivenorth/loop-sdk node-forge
# or
npm install @fivenorth/loop-sdk node-forgeNote: node-forge is a peer dependency and must be installed manually when using the server SDK. It's not required for browser usage.
import { loop } from '@fivenorth/loop-sdk/server';
// Initialize with private key
loop.init({
privateKey: process.env.PRIVATE_KEY, // hex-encoded Ed25519 private key
partyId: process.env.PARTY_ID, // your party ID
network: 'local', // or 'devnet', 'mainnet'
walletUrl: process.env.WALLET_URL, // optional
apiUrl: process.env.API_URL, // optional
});
// Authenticate to get API access
await loop.authenticate();
// Get the provider to interact with the ledger
const provider = loop.getProvider();
// List holdings
const holdings = await provider.getHolding();
console.log(holdings);
// Get active contracts
const contracts = await provider.getActiveContracts({
templateId: '#splice-amulet:Splice.Amulet:Amulet'
});
// Transfer tokens
const preparedPayload = await provider.transfer(
'recipient::partyId',
1,
{
instrument_admin: '',
instrument_id: 'Amulet',
},
{
requestedAt: new Date(),
executeBefore: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
);
// Execute the transaction
const result = await loop.executeTransaction(preparedPayload);
console.log('Transfer result:', result);Loop supports two server SDK fee flows: Fee Balance, where users maintain a prepaid balance and Canton deducts transaction costs from that balance, and pending network fees, where a transaction can create a separate fee payment that must be paid before the next transaction.
Existing server SDK integrations can keep using loop.executeTransaction(...). That helper still performs the simple one-call flow:
prepare -> sign -> execute
Use the explicit Fee Balance flow when you want the SDK to check and top up the authenticated party's Fee Balance before execution:
prepareSubmission -> ensureFeeBalance -> sign -> executeSubmission
loop.prepareSubmission(...) prepares the transaction and returns Fee Balance estimate fields in the same response. loop.ensureFeeBalance(...) checks the current Fee Balance and tops up only if needed.
Browser / WalletConnect dApps do not manage Fee Balance directly. Users maintain their Fee Balance in the Loop wallet, and the wallet handles Fee Balance prompts during browser signing/submission flows.
Top-ups are scoped to the authenticated party from loop.init({ partyId, privateKey }). They do not credit arbitrary accounts. Keep a Fee Balance reserve before submitting transactions. If the balance is too low to pay for a top-up transaction, topUpFeeBalance() may fail and the account may require operator support.
Recommended flow:
- call
loop.prepareSubmission(...)to prepare the transaction and inspect the expected Fee Balance cost before execution - call
loop.ensureFeeBalance({ requiredCC })to check the current Fee Balance and top up if needed - still handle transaction failures from Canton as the final source of truth
By default, ensureFeeBalance(...) keeps a 10 CC reserve and tops up at least 25 CC when the balance is too low. If the shortfall is larger, it tops up enough to cover requiredCC + reserveCC, plus the reserve as a cushion for the network fee for the top-up transaction. Override reserveCC or topUpAmountCC only if your integration needs different behavior.
import { loop, PaymentRequiredError } from '@fivenorth/loop-sdk/server';
let preparedSubmission;
try {
preparedSubmission = await loop.prepareSubmission(preparedPayload);
if (!preparedSubmission.estimated_network_fee_amount) {
throw new Error('Prepare did not return a Fee Balance estimate.');
}
const feeBalance = await loop.ensureFeeBalance({
requiredCC: preparedSubmission.estimated_network_fee_amount,
});
if (feeBalance.topped_up) {
preparedSubmission = await loop.prepareSubmission(preparedPayload);
}
} catch (error) {
if (!(error instanceof PaymentRequiredError) || error.trackingId || !error.requiredBalanceCC) {
throw error;
}
await loop.ensureFeeBalance({
requiredCC: error.requiredBalanceCC,
});
preparedSubmission = await loop.prepareSubmission(preparedPayload);
}
const signature = loop.getSigner().signTransactionHash(preparedSubmission.transaction_hash);
await loop.executeSubmission({
command_id: preparedSubmission.command_id,
transaction_data: preparedSubmission.transaction_data,
signature,
});You should still handle PaymentRequiredError as a fallback. A PaymentRequiredError with a trackingId is a pending network fee and can be paid with payGas(...). A PaymentRequiredError without a trackingId can be a Fee Balance failure; check error.message / error.code, top up Fee Balance if needed, and retry the original transaction.
import { loop, PaymentRequiredError } from '@fivenorth/loop-sdk/server';
try {
await loop.executeTransaction(preparedPayload);
} catch (error) {
if (error instanceof PaymentRequiredError) {
if (!error.trackingId) {
console.log('Transaction needs more Fee Balance:', error.message);
await loop.topUpFeeBalance('25');
return;
}
const dueGas = await loop.checkDueGas(error.trackingId);
console.log('Pending network fee amount:', dueGas.gas_amount);
await loop.payGas(error.trackingId);
} else {
throw error;
}
}Example ideas:
- List pending transfers
- Accept a pending transfer
- Automated transaction processing
This section is only if you want to actively develop the SDK itself. To use the SDK, follow the #Usage Guide section
To install dependencies:
bun installTo run the dev server, that is also auto re-compile the sdk:
bun startUpon doing so you can visit http://localhost:3030/ to see the local demo app, serve in demo/test.html and SDK is auto compile so you can actively working and trying out the SDK.