Skip to content

Transactions

Most “blockchain APIs” ask you to hand over a private key so the server can sign on your behalf. Smart Engines does not. Every value movement starts from an entity: an on-chain account whose signing key was created by DKG across the validators and exists only as shares. The cluster prepares the transaction, the validators sign it under the entity’s rules, and where the entity’s custody is shared with you, your own signature is required as well.

This is the transaction sovereignty model.

sequenceDiagram
  participant App as Your app
  participant SDK as baas.entities
  participant H as Host
  participant V as Validators (TSS)
  participant Chain as Blockchain

  App->>SDK: transfer(entityId, { chain, to, amount })
  SDK->>H: POST /api/v3/baas/entities/:entityId/transfer
  H->>H: check the caller owns the entity
  H->>V: prepare (the entity is the source and fee payer)
  V->>V: build the tx, check the entity's rules, threshold-sign
  V->>Chain: broadcast (Hedera, XRPL)
  Chain-->>V: result
  V-->>H: { transactionId, status, broadcast: true }
  H-->>SDK: result
  SDK-->>App: result

The entity is always the source and the fee payer. The host resolves both from the entity record, never from the request body, so a caller cannot name someone else’s account as the payer or drain an account it does not own.

Every value-movement method takes a chain field. It is the ChainType union, never a hard-coded literal, and it must be a chain the entity has an account on. Two chains are live today:

Chain Status
XRPL Live
Hedera Live
Stellar, Polkadot, Solana, Bitcoin Coming next
Ethereum, Polygon (EVM) Postponed
import { BaasClient } from '@hsuite/smart-engines-sdk';
const baas = await BaasClient.connectToCluster({ network: 'testnet', appId: 'my-app' });
await baas.authenticate({ chain: 'xrpl', walletAddress, publicKey, signFn });
const result = await baas.entities.transfer('entity-123', {
chain: 'xrpl',
to: 'rRecipient...',
amount: '1000000', // smallest unit: 1,000,000 drops = 1 XRP
});

Amounts are strings in the chain’s smallest unit (drops on XRPL, tinybar on Hedera), which avoids floating-point loss.

These calls act as the authenticated session wallet. To act for one of your end users, pass onBehalfOf (that user’s customer-session token) in the options bag. A forCustomer view refuses these calls without it.

The PreparedEntityTransactionResult you get back

Section titled “The PreparedEntityTransactionResult you get back”
Field Type Description
chain string The chain the transaction targets
transactionId string A pre-assigned id, or the real on-chain id when broadcast is true
transactionBytes string The prepared transaction
transactionType string e.g. Payment, CryptoTransfer
payerAccountId string The fee payer: always the entity’s own account
expiresAt string ISO expiry of the prepared transaction
requiredSignatures number Signatures still needed; 0 means ready to submit
readyToSubmit boolean Whether the bytes already carry enough signatures
validatorSignatures EntityValidatorSignature[] The validators’ signatures (metadata)
transactionJson object Chain-native transaction JSON (e.g. the decoded XRPL tx)
broadcast boolean true when the validators submitted the transaction themselves. See Server-side broadcast
status string The on-chain result (SUCCESS on Hedera, tesSUCCESS on XRPL). Present only when broadcast is true
metadata object Chain-specific metadata
idempotencyKey string Returned by prepareWithdraw. Echo it on executeWithdraw

Moves a native asset or a fungible token from an account entity.

const result = await baas.entities.transfer('entity-123', {
chain: 'xrpl',
to: 'rRecipient...',
amount: '1000000',
tokenId: 'HST.rIssuer...', // optional: omit for the native asset
memo: 'invoice 42', // optional
});
Field Required Notes
chain yes A chain the entity has an account on
to yes Destination account or address
amount yes String, in the chain’s smallest unit
tokenId no Omit for the chain’s native asset
memo no Carried by the transaction

transfer has no execute half. An entity whose custody you share (it needs your co-signature) moves value with prepareWithdraw and executeWithdraw.

On Hedera and XRPL, once a transfer or withdrawal carries every signature it needs, the validators submit it to the chain themselves and return the real on-chain result:

const result = await baas.entities.transfer('entity-123', {
chain: 'xrpl',
to: 'rDest...',
amount: '1000000',
});
if (result.broadcast) {
// Already on chain: transactionId is the real on-chain id.
console.log(result.status); // 'tesSUCCESS' (XRPL) / 'SUCCESS' (Hedera)
console.log(result.transactionId);
} else {
// Still needs a signature (e.g. your co-signature on a shared-custody entity).
console.log(result.requiredSignatures, result.transactionBytes);
}

A broadcast fails closed: if the submit fails, the call returns an error. It never reports success for a transaction that did not land.

Shared custody uses prepareWithdraw then executeWithdraw. When the entity needs your co-signature, prepare the withdrawal, sign the returned bytes with your wallet, and hand them back. The validators add their threshold signatures and broadcast:

const prepared = await baas.entities.prepareWithdraw('entity-123', {
chain: 'xrpl',
destination: 'rDest...',
amount: '1000000',
appOwnerPublicKey: '02ab...', // required for a shared-custody entity on XRPL and Hedera
});
const signedTransactionBytes = await signWithYourWallet(prepared.transactionBytes);
const result = await baas.entities.executeWithdraw('entity-123', {
chain: 'xrpl',
signedTransactionBytes,
appOwnerPublicKey: '02ab...', // the same key as the prepare
idempotencyKey: prepared.idempotencyKey, // echoed verbatim
});
console.log(result.broadcast, result.status); // true, 'tesSUCCESS'

Mints more supply of a fungible token whose supply authority is the entity (the entity is the issuer or treasury).

const result = await baas.entities.mint('entity-123', {
chain: 'xrpl',
tokenId: 'HST.rIssuer...',
amount: '1000',
memo: 'monthly issuance', // optional
});

Fields: chain, tokenId, amount (smallest unit), optional memo.

Burns supply of a token whose supply authority is the entity.

const result = await baas.entities.burn('entity-123', {
chain: 'hedera',
tokenId: '0.0.45678',
amount: '500',
});

Fields: chain, tokenId, amount (smallest unit), optional memo.

baas.entities also covers withdrawals, compliance, trust lines and topic messages. All take the entity id first and an options bag last:

Method Route (/api/v3/baas/entities/:entityId/...) Returns
prepareWithdraw(id, { chain, destination, amount, tokenId?, memo?, appOwnerPublicKey?, idempotencyKey? }) withdraw/prepare PreparedEntityTransactionResult
executeWithdraw(id, { chain, signedTransactionBytes, appOwnerPublicKey?, idempotencyKey? }) withdraw/execute ExecutedEntityTransactionResult
compliance(id, { action, chain, tokenId, account?, amount?, memo? }) compliance PreparedEntityTransactionResult
trustline(id, { chain, currency, issuerAddress, limit? }) trustline PreparedEntityTransactionResult
prepareSubmitMessage(id, { message, payerAccountId? }) message PreparedEntityTransactionResult
submitMessage(id, { message, payerAccountId?, fundWith }) message SubmitTopicMessageResult

compliance takes action: 'pause' | 'restrict' | 'wipe'.

A few operations exist on only one live chain:

Operation Chain Notes
trustline XRPL Opens a trust line from the entity’s account to an issued currency
prepareSubmitMessage / submitMessage Hedera Submits a message (up to 1,024 bytes) to a topic entity. submitMessage pays the fee with your fundWith callback and returns the transactionId
compliance XRPL pause and restrict use XRPL freezes; wipe works only on a token created with it enabled. A token created through the API on Hedera has no pause, restrict or wipe key

A value-movement call can come back with two very different kinds of “no”, and a client has to tell them apart, because one is a permanent verdict and the other is the system pacing you.

What happened HTTP Body carries Retry?
A rule blocked the operation (for example the destination is not on the entity’s allowlist) 403 the rule’s reason in message Never (isRetryable === false)
A rate limit, cooldown or velocity gate refused for now 429 code: 'POLICY_DENIED' and retryAfterSeconds Yes, after the wait (isRetryable === true)

A rule deny (403) — deterministic, don’t retry it

Section titled “A rule deny (403) — deterministic, don’t retry it”

A rule deny reaches a BaasClient call as a 403 whose message is the rule’s reason. The validators never return signed bytes for a denied operation.

The SDK also exports an isRuleRejected type guard. It matches only a body that carries the full rule_rejected envelope (error: 'rule_rejected', reason and the ruleAtoms ids). Entity calls made through the host do not forward that envelope today: the reason arrives in message, ruleAtoms does not arrive, and isRuleRejected returns false. Treat any 403 from a value-movement call as final.

import { isSmartEngineSdkError } from '@hsuite/smart-engines-sdk';
try {
await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rDest...', amount: '1000000' });
} catch (err) {
if (isSmartEngineSdkError(err) && err.statusCode === 403) {
// A verdict: show the reason, don't loop.
console.log(`Blocked: ${err.message}`); // the rule's reason
return;
}
throw err;
}

A policy hold (429) — transient, honor retryAfterSeconds

Section titled “A policy hold (429) — transient, honor retryAfterSeconds”

A rate limit, cooldown or velocity gate is a temporary refusal, returned as a 429 with code: 'POLICY_DENIED' and a whole-second retryAfterSeconds in the body. err.isRetryable is true, and the correct move is to wait that long and retry the same request:

import { isSmartEngineSdkError } from '@hsuite/smart-engines-sdk';
async function transferWithPolicyRetry() {
for (;;) {
try {
return await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rDest...', amount: '1000000' });
} catch (err) {
if (
isSmartEngineSdkError(err) &&
err.statusCode === 429 &&
err.details?.code === 'POLICY_DENIED'
) {
const waitMs = (err.details.retryAfterSeconds ?? 60) * 1000;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue; // same request, after the cooldown
}
throw err;
}
}
}

Because the same transfer sent within 15 minutes is merged into one operation, this retry cannot pay twice.

Full body shapes, the complete status-code table and a generic retry helper are in Error Handling.