Skip to main content

Contract Driver Script

A starting point, not a product. This script is a reference implementation that drives the caller contract end to end. It is unaudited, and Franklin Templeton does not maintain it on your behalf — once you copy it, it is yours to review, test and keep current.

It needs Node 18+, ethers v6 and dotenv. It is an ES module, so save it as driver.mjs — that way it does not depend on your package's "type" field.

Configuration​

Config comes from a .env file:

RPC_URL=<your RPC endpoint>
TESTNET_OWNER_KEY=<private key of the caller contract's owner>
POOL=<SwapPool proxy address>
CALLER=<your deployed caller proxy address>
FROM_TOKEN=<input token address>
TO_TOKEN=<output token address>
DESTINATION=<recipient, or blank for the contract to keep the output>
ALLOW_CHAIN_ID=<blank; set only to run against a local fork of a listed chain>

The addresses are supplied during onboarding. Never commit .env.

Test networks only​

This script signs as the caller contract's owner from a single raw key. That key can withdraw every token the contract holds and replace its implementation, and it also holds the input tokens the script moves in at step 1 — so one key carries both your float and your upgrade authority.

Run it this way on a test network only. The script refuses chains 1, 10, 56, 137, 8453, 42161 and 43114. That is a denylist rather than a guarantee: an unlisted mainnet still runs, and a local fork inherits its parent's chain ID, so allow one with ALLOW_CHAIN_ID for that run instead of removing the check.

For anything that handles real value, keep whatever key signs for you in a keystore, an HSM or a cloud KMS rather than in a file.

A caller owned by a multi-signature wallet or a timelock cannot be driven by this script at all: every function it calls is owner-only, and a multisig executes those through its own transaction path, which this page does not cover. Raise that during onboarding with your Sales Contact.

Allow-list the recipient once, before the first run with a DESTINATION

From the owner, call setAllowedDestination(destination, true) on your caller. Until you do, every call that names a third-party destination reverts DESTINATION_NOT_ALLOWED — and that error comes from your own contract, not from the pool.

With executeSwapWithFloor, address(0) delivers to the contract itself and needs no entry. The other two destination-bearing calls treat address(0) as a recipient, and it can never be allow-listed: setAllowedDestination rejects it as ZERO_DESTINATION.

This script uses executeSwapToDestination, which cannot enforce a floor

That is fine for a demonstration on a test network. For anything real, switch to executeSwapWithFloor and pass the net you just quoted — see Step 3: Set minAmountOut.

The allow-list call is a one-off. The script below already carries setAllowedDestination in its caller ABI, so run this line from inside it once, before your first run with a DESTINATION:

await (await caller.setAllowedDestination(DESTINATION, true)).wait();

The script​

// BenjiSwap contract-to-contract driver.
// Makes a smart contract swap tokens by itself and prints a plain-English result.
import "dotenv/config";
import { ethers } from "ethers";

const {
RPC_URL,
TESTNET_OWNER_KEY,
POOL,
CALLER,
FROM_TOKEN,
TO_TOKEN,
DESTINATION,
ALLOW_CHAIN_ID,
} = process.env;
if (
!RPC_URL ||
!TESTNET_OWNER_KEY ||
!POOL ||
!CALLER ||
!FROM_TOKEN ||
!TO_TOKEN
) {
console.error(
"Missing config in .env (need RPC_URL, TESTNET_OWNER_KEY, POOL, CALLER, FROM_TOKEN, TO_TOKEN).",
);
process.exit(1);
}

let DEST = null;
try {
DEST = DESTINATION ? ethers.getAddress(DESTINATION) : null;
} catch {
console.error(`DESTINATION in .env is not a valid address: ${DESTINATION}`);
process.exit(1);
}
const recipient = DEST ?? ethers.getAddress(CALLER);

const p = new ethers.JsonRpcProvider(RPC_URL);

// This script signs from a raw key in a file, so it refuses the chains listed below. A local
// fork inherits its parent's chain ID: set ALLOW_CHAIN_ID to that ID for one run rather than
// removing the check, so pointing at the real chain later is still refused.
const MAINNETS = new Set([1n, 10n, 56n, 137n, 8453n, 42161n, 43114n]);
const { chainId } = await p.getNetwork();
if (MAINNETS.has(chainId) && ALLOW_CHAIN_ID !== chainId.toString()) {
console.error(
`Refusing to run: chain ${chainId} is a mainnet and TESTNET_OWNER_KEY is a plaintext key.\n` +
`If this is a local fork, set ALLOW_CHAIN_ID=${chainId} in .env for this run.`,
);
process.exit(1);
}

const signer = new ethers.Wallet(TESTNET_OWNER_KEY, p);

const erc20 = (a, run = p) =>
new ethers.Contract(
a,
[
"function balanceOf(address) view returns (uint256)",
"function transfer(address,uint256) returns (bool)",
],
run,
);
const pool = new ethers.Contract(
POOL,
[
"function quoteNet(address,address,uint256,address) view returns (uint256 net, uint256 fee)",
"function registeredTokens(address) view returns (bool isRegistered, uint8 decimals)",
],
p,
);
const caller = new ethers.Contract(
CALLER,
[
"function executeSwap(address,address,address,uint256)",
"function executeSwapToDestination(address,address,address,uint256,address)",
"function executeSwapWithFloor(address,address,address,uint256,uint256,address)",
"function setAllowedDestination(address,bool)",
],
signer,
);

// Read decimals from the pool, not from the tokens: the pool uses the value it cached.
const [fromRegistered, fromDec] = await pool.registeredTokens(FROM_TOKEN);
const [toRegistered, toDec] = await pool.registeredTokens(TO_TOKEN);
if (!fromRegistered || !toRegistered) {
console.error(
"One of the tokens is not registered on this pool. Ask your Sales Contact.",
);
process.exit(1);
}

const fFrom = (n) => ethers.formatUnits(n, fromDec);
const fTo = (n) => ethers.formatUnits(n, toDec);
const pct = (fee, gross) =>
gross === 0n ? "0" : (Number((fee * 1_000_000n) / gross) / 10000).toString();

// One whole input token. If you replace this with your own figure, keep the rounding and
// the guard below: a sub-step amount rounds to zero here.
const step = fromDec > toDec ? 10n ** (fromDec - toDec) : 1n;
const AMOUNT = (10n ** fromDec / step) * step;
if (AMOUNT === 0n) {
console.error("Amount rounds to zero at the output token precision.");
process.exit(1);
}

const bar = "=".repeat(60);
console.log(`\n${bar}`);
console.log(" BenjiSwap contract-to-contract swap");
if (DEST) console.log(` Delivering the output to ${DEST}`);
console.log(`${bar}\n`);

// Quote as the CALLER: the fee is keyed on the calling contract, not on you.
const [net, fee] = await pool.quoteNet(FROM_TOKEN, TO_TOKEN, AMOUNT, CALLER);

const have = await erc20(FROM_TOKEN).balanceOf(CALLER);
if (have < AMOUNT) {
console.log(`Step 1 of 3 Give the contract ${fFrom(AMOUNT)} to swap…`);
await (
await erc20(FROM_TOKEN, signer).transfer(CALLER, AMOUNT - have)
).wait();
} else {
console.log(
`Step 1 of 3 The contract already holds ${fFrom(AMOUNT)} to swap.`,
);
}

console.log(`Step 2 of 3 The contract swaps ${fFrom(AMOUNT)}…`);
if (DEST)
console.log(
` - the output goes to ${DEST}, not back to the contract`,
);
if (fee > 0n) {
console.log(
` - it pays a ${pct(fee, net + fee)}% fee (${fTo(fee)})`,
);
console.log(` - so it should receive ${fTo(net)}`);
} else {
console.log(
` - no fee, so it should receive ${fTo(net)} (1-to-1)`,
);
}

const before = await erc20(TO_TOKEN).balanceOf(recipient);
const tx = DEST
? await caller.executeSwapToDestination(
POOL,
FROM_TOKEN,
TO_TOKEN,
AMOUNT,
DEST,
)
: await caller.executeSwap(POOL, FROM_TOKEN, TO_TOKEN, AMOUNT);
const r = await tx.wait();
const delta = (await erc20(TO_TOKEN).balanceOf(recipient)) - before;

if (r.status === 1 && delta === net) {
console.log("Step 3 of 3 Done \n");
console.log(
` Result: ${DEST ? `${DEST} received` : "the contract received"} ${fTo(delta)} for ${fFrom(AMOUNT)}${fee > 0n ? ` (after a ${fTo(fee)} fee)` : ""}.`,
);
console.log(` Transaction: ${tx.hash}\n`);
} else {
console.log("Step 3 of 3 Something looked off ⚠\n");
console.log(
` Expected ${fTo(net)} but ${DEST ? DEST : "the contract"} received ${fTo(delta)}.`,
);
console.log(` Transaction: ${tx.hash}\n`);
process.exitCode = 1;
}

Why it reads decimals from the pool​

The pool converts using the decimals value it cached when the token was registered, not whatever the token reports now. Reading from the pool means your figures match what the pool will actually do. See Check eligibility for the read that returns it.

Why it quotes as the caller​

The fee is keyed on the calling contract, not on the wallet that triggers the call — so the quote passes CALLER, not the signer. Quoting as yourself returns a figure that does not apply to this trade.

The quote is read immediately before the swap because the pool's fees, authorizations, minimums and pause state can change without notice. See Preview a swap for the guarantees a quote does and does not give you.