Skip to main content
IntermediateEst7 minsdk-core15.4.1Project build checked

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
package.json
{
"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"
}
}
tsconfig.json
{
"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
// 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
// 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;
});
src/ping-pong.abi.json
{
"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
// 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

Pitfall 1: sending the wrong amount is an on-chain-enforced failure for this contract

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.

Pitfall 2: nativeTransferAmount is separate from arguments

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.

Pitfall 3: this recipe deliberately never succeeds against a real balance

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