Call a payable endpoint with EGLD
Call a payable smart contract endpoint, attaching EGLD via
nativeTransferAmount (the same option works for plain EGLD, no token
identifier needed).
Target: the real, currently-deployed devnet ping-pong contract,
erd1qqqqqqqqqqqqqpgqm6ad6xrsjvxlcdcffqe8w58trpec09ug9l5qde96pq, the exact
address mx-template-dapp's own src/config/config.devnet.ts ships as its
default contractAddress. Its own ABI docs: "A contract that allows anyone to
send a fixed sum, locks it for a while and then allows users to take it back...
Only the set amount can be ping-ed, no more, no less." This recipe queries that
fixed amount first (getPingAmount(), the same pattern as
Query a read-only view)
rather than hardcoding it.
When NOT to use this recipe: for a browser dApp where the end user's own
wallet signs, see
Sign and send a transaction
and mx-template-dapp's PingPongAbi widget (same ABI plus Factory plus
nativeTransferAmount pattern, driven from a connected wallet). This recipe is
for when your own code holds the private key.
Prerequisites
- Node.js >= 20.19.0.
- A devnet wallet, see Send EGLD to an address's Prerequisites for how to generate a throwaway one. No devnet EGLD required, see "How it works" below.
Install
mkdir call-payable-endpoint
cd call-payable-endpoint
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-call-payable-endpoint",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — call a payable smart contract endpoint, attaching EGLD via nativeTransferAmount, against a live devnet contract with sdk-core's SmartContractController.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"axios": "1.18.1",
"bignumber.js": "9.3.1",
"protobufjs": "7.6.5"
},
"devDependencies": {
"@types/node": "20.19.43",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.19.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false,
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
// src/account.ts — load a devnet Account from a PEM file and prime its
// local nonce from the network. Framework-agnostic sdk-core, no sdk-dapp
// involved — this is the "your own code holds the keys" pattern (a script,
// a bot, a backend service), not a browser wallet-connected flow.
//
// Fetch the account nonce from the network before sending, then increment
// locally with getNonceThenIncrement(). This helper implements the fetch half;
// see the manage-nonces recipe for the increment half in depth.
import { Account } from '@multiversx/sdk-core';
import type { DevnetEntrypoint } from '@multiversx/sdk-core';
/**
* Loads an Account from a PEM file and sets its local `nonce` from the
* network's current value — the "fetch" half of the fetch-then-increment
* pattern (see the manage-nonces recipe for the "increment" half across a
* batch of sends).
*
* `entrypoint.recallAccountNonce(address)` is a thin convenience wrapper
* around `networkProvider.getAccount(address).nonce`
* (node_modules/@multiversx/sdk-core/out/entrypoints/entrypoints.js) — used
* here instead of constructing a network provider by hand.
*/
export async function loadDevnetAccount(
entrypoint: DevnetEntrypoint,
pemPath: string,
): Promise<Account> {
const account = await Account.newFromPem(pemPath);
account.nonce = await entrypoint.recallAccountNonce(account.address);
return account;
}
// src/index.ts — CLI entry point. Queries the required ping amount, then
// calls `ping()` attaching exactly that much EGLD.
//
// Usage:
// npm run build && npm start -- <pemPath>
//
// See the prerequisites above for a devnet PEM wallet. The offline build
// checks do not require devnet EGLD; broadcasting this call does.
import * as fs from 'fs';
import * as path from 'path';
import { Abi, DevnetEntrypoint } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import { queryPingAmount, callPing, PING_PONG_CONTRACT_ADDRESS } from './callPing';
async function main(): Promise<void> {
const [pemPath] = process.argv.slice(2);
if (!pemPath) {
console.error('Usage: npm start -- <pemPath>');
process.exitCode = 1;
return;
}
const abiJson = fs.readFileSync(path.join(__dirname, '..', 'src', 'ping-pong.abi.json'), {
encoding: 'utf8',
});
const abi = Abi.create(JSON.parse(abiJson) as Record<string, unknown>);
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
console.log(`Contract: ${PING_PONG_CONTRACT_ADDRESS} (ping-pong, live devnet)`);
const pingAmount = await queryPingAmount(entrypoint, abi);
console.log(`Required ping amount (from getPingAmount()): ${pingAmount} (smallest denomination)`);
const sender = await loadDevnetAccount(entrypoint, pemPath);
console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Calling: ping() <- payable, attaching ${pingAmount} EGLD via nativeTransferAmount`);
const { txHash } = await callPing(entrypoint, abi, sender, pingAmount);
console.log(`\nSent. Transaction hash: ${txHash}`);
console.log(`Explorer: https://devnet-explorer.multiversx.com/transactions/${txHash}`);
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
{
"buildInfo": {
"rustc": {
"version": "1.61.0-nightly",
"commitHash": "1d9c262eea411ec5230f8a4c9ba50b3647064da4",
"commitDate": "2022-03-26",
"channel": "Nightly",
"short": "rustc 1.61.0-nightly (1d9c262ee 2022-03-26)"
},
"contractCrate": {
"name": "ping-pong",
"version": "0.0.2",
"git_version": "23ff9bd"
},
"framework": {
"name": "elrond-wasm",
"version": "0.34.1"
}
},
"docs": [
"A contract that allows anyone to send a fixed sum, locks it for a while and then allows users to take it back.",
"Sending funds to the contract is called \"ping\".",
"Taking the same funds back is called \"pong\".",
"",
"Restrictions:",
"- Only the set amount can be `ping`-ed, no more, no less.",
"- `pong` can only be called after a certain period after `ping`."
],
"name": "PingPong",
"constructor": {
"docs": [
"Necessary configuration when deploying:",
"`ping_amount` - the exact amount that needs to be sent when `ping`-ing. ",
"`duration_in_seconds` - how much time (in seconds) until `pong` can be called after the initial `ping` call ",
"`token_id` - Optional. The Token Identifier of the token that is going to be used. Default is \"EGLD\"."
],
"inputs": [
{
"name": "ping_amount",
"type": "BigUint"
},
{
"name": "duration_in_seconds",
"type": "u64"
},
{
"name": "opt_token_id",
"type": "optional<EgldOrEsdtTokenIdentifier>",
"multi_arg": true
}
],
"outputs": []
},
"endpoints": [
{
"docs": [
"User sends some tokens to be locked in the contract for a period of time."
],
"name": "ping",
"mutability": "mutable",
"payableInTokens": ["*"],
"inputs": [],
"outputs": []
},
{
"docs": [
"User can take back funds from the contract.",
"Can only be called after expiration."
],
"name": "pong",
"mutability": "mutable",
"inputs": [],
"outputs": []
},
{
"name": "didUserPing",
"mutability": "readonly",
"inputs": [
{
"name": "address",
"type": "Address"
}
],
"outputs": [
{
"type": "bool"
}
]
},
{
"name": "getPongEnableTimestamp",
"mutability": "readonly",
"inputs": [
{
"name": "address",
"type": "Address"
}
],
"outputs": [
{
"type": "u64"
}
]
},
{
"name": "getTimeToPong",
"mutability": "readonly",
"inputs": [
{
"name": "address",
"type": "Address"
}
],
"outputs": [
{
"type": "optional<u64>",
"multi_result": true
}
]
},
{
"name": "getAcceptedPaymentToken",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "EgldOrEsdtTokenIdentifier"
}
]
},
{
"name": "getPingAmount",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "BigUint"
}
]
},
{
"name": "getDurationTimestamp",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "u64"
}
]
},
{
"name": "getUserPingTimestamp",
"mutability": "readonly",
"inputs": [
{
"name": "address",
"type": "Address"
}
],
"outputs": [
{
"type": "u64"
}
]
}
],
"events": [
{
"identifier": "pongEvent",
"inputs": [
{
"name": "user",
"type": "Address",
"indexed": true
}
]
}
],
"hasCallback": false,
"types": []
}
Calling the payable endpoint
// src/callPing.ts — calling a PAYABLE endpoint, attaching EGLD via
// `nativeTransferAmount` (the same option works for plain EGLD, no token
// identifier needed).
//
// Target: the real, currently-deployed devnet **ping-pong** contract —
// erd1qqqqqqqqqqqqqpgqm6ad6xrsjvxlcdcffqe8w58trpec09ug9l5qde96pq — the exact
// contract address mx-template-dapp's own config.devnet.ts ships as its default
// contractAddress, and the same contract mx-template-dapp's PingPongAbi widget
// calls. Its ABI docs: "A contract that allows anyone to send a fixed sum,
// locks it for a while and then allows users to take it back... Only the set
// amount can be `ping`-ed, no more, no less." That fixed amount is itself a
// read-only view (`getPingAmount`) — this recipe queries it first, then uses
// the result as the payment.
import { Address } from '@multiversx/sdk-core';
import type { Abi, Account, DevnetEntrypoint } from '@multiversx/sdk-core';
export const PING_PONG_CONTRACT_ADDRESS =
'erd1qqqqqqqqqqqqqpgqm6ad6xrsjvxlcdcffqe8w58trpec09ug9l5qde96pq';
/**
* Reads the exact EGLD amount this ping-pong instance requires for `ping()` — a
* read-only view, no wallet needed (same pattern as the "Query a read-only
* view" recipe). This deployed instance returns exactly 1000000000000000000
* (1 EGLD).
*/
export async function queryPingAmount(entrypoint: DevnetEntrypoint, abi: Abi): Promise<bigint> {
const controller = entrypoint.createSmartContractController(abi);
const [pingAmount] = await controller.query({
contract: Address.newFromBech32(PING_PONG_CONTRACT_ADDRESS),
function: 'getPingAmount',
arguments: [],
});
return BigInt((pingAmount as { toString(base: number): string }).toString(10));
}
export interface CallPingOutput {
txHash: string;
}
/**
* Calls `ping()`, attaching exactly `pingAmountInSmallestDenomination` of EGLD
* via `nativeTransferAmount` — the same option `createTransactionForExecute`
* uses for ANY native EGLD payment, token payments, or both together. `ping`
* itself declares zero ABI inputs (`arguments: []`); the payment is not an
* argument, it is the transaction's attached value.
*/
export async function callPing(
entrypoint: DevnetEntrypoint,
abi: Abi,
sender: Account,
pingAmountInSmallestDenomination: bigint,
): Promise<CallPingOutput> {
const controller = entrypoint.createSmartContractController(abi);
const transaction = await controller.createTransactionForExecute(sender, sender.getNonceThenIncrement(), {
contract: Address.newFromBech32(PING_PONG_CONTRACT_ADDRESS),
function: 'ping',
arguments: [],
gasLimit: 6_000_000n,
nativeTransferAmount: pingAmountInSmallestDenomination,
});
const txHash = await entrypoint.sendTransaction(transaction);
return { txHash };
}
Run it
npm start -- <pemPath>
Expected output:
Contract: erd1qqqqqqqqqqqqqpgqm6ad6xrsjvxlcdcffqe8w58trpec09ug9l5qde96pq (ping-pong, live devnet)
Required ping amount (from getPingAmount()): 1000000000000000000 (smallest denomination)
Sender: erd1... (nonce 0)
Calling: ping() <- payable, attaching 1000000000000000000 EGLD via nativeTransferAmount
Sent. Transaction hash: <64-char hex hash>
(An unfunded wallet gets a clean "insufficient funds" rejection here instead of a hash, see "How it works.")
How it works
nativeTransferAmount attaches EGLD to a call without it being an ABI
argument. ping()'s ABI declares zero inputs; the payment is not a function
argument, it is the transaction's attached value, same as sending EGLD to any
address, except this transaction also carries data: "ping" so the contract's
VM execution knows which endpoint to run. Verified directly: sending this call
logged "value":"1000000000000000000","data":"cGluZw==", where
1000000000000000000 is exactly 1 EGLD and cGluZw== decodes to the literal
string "ping" (no @-separated arguments, since none are declared).
The required amount is discovered, not hardcoded. queryPingAmount() calls
getPingAmount(), a read-only view, no wallet needed, before callPing() builds
a transaction. This deployed instance requires exactly 1000000000000000000
(1 EGLD). A different ping-pong deployment could require a different fixed
amount, set once at deploy time; querying it is what makes this recipe correct
for whichever instance you point it at.
Why an unfunded wallet is enough to verify this end to end. Sending this
transaction from a freshly generated, intentionally unfunded devnet wallet was
rejected with a clean, specific insufficient funds error, never a
malformed-request or bad-signature error. That is proof the whole pipeline
(querying the live required amount, building the payable call, attaching
nativeTransferAmount, signing) produced a well-formed transaction, without
needing to fund the wallet with 1+ EGLD.
The crowdfunding tutorial contract in mx-sdk-rs
(contracts/examples/crowdfunding) has a fund() endpoint of the same shape,
payable, accepts EGLD, no arguments, if you want the same pattern in a Rust
contract you deploy yourself.
Pitfalls
ping-pong's own logic accepts only its exact configured ping_amount, "no more,
no less." Always query the real value (getPingAmount()) instead of assuming a
round number; this recipe's default is 1 EGLD only because that is what this
particular deployed instance happens to require.
It is easy to assume payment has to be encoded as an ABI argument somehow. It
does not. A payable endpoint with real arguments would use both arguments
(for the declared inputs) and nativeTransferAmount (for the payment) together.
It proves the request is well-formed via a clean "insufficient funds" rejection,
not a confirmed on-chain state change. Funding the wallet with at least 1 EGLD
(plus gas) lets the same code actually ping the contract for real; nothing in
callPing.ts changes for that case.
See also
- Query a read-only view
is the
getPingAmount()query pattern this recipe depends on. - Call a contract endpoint with native JS args
is the non-payable counterpart, calling adder's
add(value)instead. - Load an ABI is the ABI-loading step both of the above build on.