A few weeks ago, @bkchr published a post titled: Remote Proxies For The Braves. In it, he shared a PJS snippet to demonstrate how to send a remote proxy transaction.
However -as he already warned- the example is meant for the brave. While it technically works, it has a number of caveats and pitfalls that can cause transactions to fail unpredictably. I’ll explain those pitfalls in detail shortly.
But first, let’s look at a better alternative.
Remote Proxies Made Simple with PAPI
Using PAPI, you can create a Remote Proxy Transaction that’s significantly more robust and simpler to implement. Here’s how easy it is:
import { createClient, Enum } from "polkadot-api"
import { connectInjectedExtension } from "polkadot-api/pjs-signer"
import { getWsProvider } from "polkadot-api/ws-provider/web"
import { getRemoteProxySdk } from "@polkadot-api/sdk-remote-proxy"
const relayClient = createClient(getWsProvider("wss://rpc.ibp.network/kusama"))
const ahClient = createClient(getWsProvider("wss://sys.ibp.network/statemine"))
const sdk = getRemoteProxySdk(relayClient, ahClient)
// In this example we are using a browser-extension account, but this would
// also work with any other PolkadotSigner (ledger-signer, a raw-signer, etc)
const account = (await connectInjectedExtension("talisman"))
.getAccounts()
.find((x) => x.name === "YourAccountsName")!
// The relay-chain proxy account which is controled by the `account`
const PROXIED_ACCOUNT = "F5TNY7m4sntpbzEKvid6HZhktUXLWEEmqwVZwYXS9ZXKDf2"
// The signer that will take care of all the complexity
const signer = sdk.getProxiedSigner(PROXIED_ACCOUNT, account.polkadotSigner)
// The call that will be executed by the proxied account
const tx = ahClient.getUnsafeApi().tx.Balances.transfer_all({
dest: Enum("Id", account.address),
keep_alive: false,
})
// Let's sign and submit our tx... And we are done!
tx.signSubmitAndWatch(signer).subscribe(console.log, console.error)
That’s it! And yes-it really is that simple.
Behind the scenes, this snippet handles a variety of edge cases and failure modes that the PJS example does not. But more on that in the final section.
What About Multisigs?
As @bkchr notes in the last section of his post, this same approach wouldn’t work for remote proxies controlled by multisigs.
“The time between generating the proof and execution of the transaction on chain can only be 1 minute in maximum, this would not work for multisigs which need to coordinate via multiple people.”
No worries. Our PAPI SDK has you covered there too.
Here’s how you do it with PAPI:
import { AccountId, Enum, createClient } from "polkadot-api"
import { connectInjectedExtension } from "polkadot-api/pjs-signer"
import { getWsProvider } from "polkadot-api/ws-provider/web"
import { getRemoteProxySdk } from "@polkadot-api/sdk-remote-proxy"
const relayClient = createClient(getWsProvider("wss://rpc.ibp.network/kusama"))
const ahClient = createClient(getWsProvider("wss://sys.ibp.network/statemine"))
const remoteProxySdk = getRemoteProxySdk(relayClient, ahClient)
// In this example we are using a browser-extension account, but this would
// also work with any other PolkadotSigner like the ledger-signer, a raw-signer, etc
const account = (await connectInjectedExtension("talisman"))
.getAccounts()
.find((x) => x.name === "YourAccountsName")!
// The signer that will take care of all the complexity
const remoteMultisigProxySigner = remoteProxySdk.getMultisigProxiedSigner(
// The relay-chain proxy account which is controled by the multisig
"GeYsALZTCjRmG6bUCqtCK7t3mYk2B6JoaFGq2JiWxv5Yvzc",
// The configuration of the multisig
{
signatories: [
"DSDpapJC2viKE4nnAaDzEN1qP7NnNDVd3y4mVM2Ggu3nk4o",
"DfQTJosoEyqD1uBqDo8TDGDdwmfgXXsQCLgLrofxyaV5zTm",
"CzJobVBH3SLeNnmbNhVotUqBgsWvK5nAjfSQ9LmSxpT3583",
],
threshold: 2,
},
// It has to be the signer of one of the `signatories`
account.polkadotSigner,
)
// The SS58 address of the multisig
const multisigAddress = AccountId().dec(remoteMultisigProxySigner.accountId)
// The tx for sending the funds from the proxy account to the multisig
const tx = ahClient.getUnsafeApi().tx.Balances.transfer_all({
dest: Enum("Id", multisigAddress),
keep_alive: false,
})
// We can now sign and submit the transaction... and we are done!
// We just have to wait for another signatory to do the same
tx.signSubmitAndWatch(remoteMultisigProxySigner).subscribe(
console.log,
console.error,
)
What This Handles for You
The SDK’s getMultisigProxiedSigner()
method wraps all of the following complexities:
-
Multisig Preparation
- Creates
Multisig.approve_as_multi
calls. - Sorts signatories.
- Calculates appropriate call weights.
- Creates
-
Execution Wrapping
- Final transaction is wrapped in
Multisig.as_multi
insideUtility.batch_all
. - Includes the
register_remote_proxy_proof
call.
- Final transaction is wrapped in
-
Mortality Management
- Ensures the
CheckMortality
extension reflects proper block context. - Applies a short expiration window to prevent wasted fees on failure.
- Ensures the
In short: you focus on logic, PAPI handles the tough parts.
Why This Is Even Possible
This works so smoothly because PAPI’s public interfaces are composable and decoupled from PAPI internals. They’re not only designed for PAPI itself-but also to interoperate with any compatible tool or library.
Here’s an example: PAPI’s public Signer
interface. As you’ll see, it doesn’t expose any internal PAPI-specific details.
That means other libraries can:
- Reuse our signers and signer-enhancers.
- Build on top of them.
- Compose and enhance them for cross-cutting concerns.
It’s unfortunate more libraries haven’t adopted the standardized interfaces we’ve promoted for over two years.
The Magic Behind the Scenes
Let’s circle back to what makes PAPI more robust than the raw PJS approach.
Handling Relay Chain Drift
What happens if, while fetching the proof from the relay chain, the latest block reported on the RemoteProxyRelayChain.BlockToRoot
storage entry of the parachain moves forward by 4 blocks?
This isn’t theoretical, it actually happens fairly often, check out what happened between blocks 9,723,687 and 9,723,688. If you check the
ParachainSystem.set_validation_data
inherent, then you will see that therelay_parent_number
field increased from28,838,143
to28,838,147
.
That’s annoying. As that new block greatly decreased the chances for your transaction to enter inside the possible window. In a case like this, PAPI will cancel the request and re-fetch the proof for the most recent valid block (e.g. block 28838147
), ensuring the transaction can be successfully included.
Protecting Against Reorgs
If there’s a re-org, your relay-chain proof might no longer be valid.
PAPI mitigates this by:
-
Aligning the
CheckMortality
block with the exact block for which the latestRemoteProxyRelayChain.BlockToRoot
was queried. -
Setting the mortality window to 8 blocks, ensuring the transaction is either:
- Included successfully, or
- Invalidated automatically (no wasted fees).
What About smoldot?
You might be wondering why the examples above use the WebSocket provider, when PAPI is generally a light-client-first library. Good catch!
The reason is that smoldot doesn’t yet support the specific RPC method required to query storage proofs.
That said, this is actively being addressed by the PAPI team. In fact, @voliva has already opened a PR to add support for this RPC to smoldot .
But if you want to start using this feature right now with a light client, you don’t have to wait, we’ve published a “patched” version of smoldot that includes the necessary RPC support.
Final Thoughts
To sum up:
- PAPI simplifies remote proxy and multisig proxy transactions.
- It safely handles complex edge cases: relay block drift, reorgs, multisig intricacies, mortality improvements.
- It uses fully composable and standardized public interfaces for greater interoperability.
- And yes, light clients are still in the picture!