Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ TypeScript SDK for building on [Rome](https://github.com/rome-protocol) — EVM
Repo-first — install from the repo, pinned to a release tag (npm publish is deferred until demand):

```
npm install github:rome-protocol/rome-sdk-ts#v0.2.0 viem @solana/web3.js @solana/spl-token
npm install github:rome-protocol/rome-sdk-ts#v0.2.1 viem @solana/web3.js @solana/spl-token
```

Imports use the package name (`@rome-protocol/sdk`) unchanged.
Expand Down Expand Up @@ -55,10 +55,12 @@ const { signature } = await submitRomeTxSolanaLane(

The wallet **signs the Solana transaction and sends it to the Solana RPC** (the proxy is used only for account emulation). On-chain, `msg.sender` is the wallet's **synthetic address** — `syntheticAddress(pubkey)` = `keccak256(pubkey)[12:]`.

**The synthetic holds nothing at rest** — a Solana user's money lives in their wallet (as SPL USDC), and value flows *through* the synthetic. So bracket a lane write with:
**The synthetic holds nothing at rest.** A Solana user's spendable balance is their **wallet's SPL ATA, exposed 1:1 as an ERC20SPL wrapper** on the EVM side (e.g. `wUSDC`) — so your app moves it with ordinary ERC20 `transfer` / `transferFrom`, *not* native `msg.value`. Value flows *through* the synthetic:

- **Fund leg** (value in) — `buildFundLeg(...)` + `submitSolanaInstructions(...)`: move USDC from the wallet into the synthetic (`ActivateAta`) before the call.
- **Sweep leg** (value out) — `buildSweepLeg(...)`: push USDC from the synthetic back to the wallet (`HelperProgram.transfer_spl`) after a withdraw.
- **Fund leg** (value in) — `buildFundLeg(...)` + `submitSolanaInstructions(...)`: move USDC from the wallet into the synthetic's ATA (`ActivateAta`) before the call.
- **Sweep leg** (value out) — `buildSweepLeg(...)`: push USDC from the synthetic's ATA back to the wallet (`HelperProgram.transfer_spl`) after a withdraw.

**Provisioning is automatic.** A brand-new synthetic's external-auth PDA doesn't exist until `create_pda` runs — and value-moving calls are signed by it. `submitRomeTxSolanaLane` provisions it transparently on first use; pass `autoProvision: false` and call `provisionSynthetic(deps)` yourself for an explicit one-time "Activate" step (gate a UI with `isSyntheticProvisioned`).

`submitRomeTxSolanaLane` also handles what a hand-built transaction gets wrong: the **ComputeBudget** (raised CU + heap), the **treasure wallet** account, and emulating account discovery **with `value`** (so value-dependent storage slots are allocated). The user needs a little **SOL** (tx fee) + **USDC** (SPL) in their wallet; there is no faucet. Full walkthrough: **Build a dual-lane app** in the [docs](https://docs.rome.builders).

Expand Down Expand Up @@ -90,6 +92,7 @@ Quote-first: the bridge API owns route + calldata; the client signs the quote's
| `u64Le` / `u8` | little-endian primitives for inner Solana instruction data |
| `submitRomeTxSolanaLane(deps, call)` | the Solana lane — a Solana wallet drives your EVM contract |
| `buildFundLeg` / `buildSweepLeg` / `submitSolanaInstructions` | value-in (ActivateAta) / value-out (transfer_spl) / native-instruction submit |
| `provisionSynthetic` / `isSyntheticProvisioned` / `buildCreatePdaCall` | fresh-synthetic provisioning (`create_pda`) — auto on first `submitRomeTxSolanaLane` |
| `syntheticAddress` / `emulateCallAccounts` / `buildDoTxUnsigned` / `buildActivateAtaInstruction` / `treasureWallet` / `balanceKeyPda` / `computeBudgetIxs` / `SyntheticNonceTracker` | Solana-lane primitives |
| `PRECOMPILE_ADDRESSES` / `CPI_ABI` / `HELPER_ABI` / `WITHDRAW_ABI` / `SYSTEM_ABI` / `SELECTORS` / `EXTERNAL_AUTHORITY_SEED` | precompile addresses, ABIs, and verified selectors |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rome-protocol/sdk",
"version": "0.2.0",
"version": "0.2.1",
"description": "Rome Protocol TypeScript SDK — submitRomeTx (the canonical Rome EVM write wrapper), the Solana lane (submitRomeTxSolanaLane — a Solana wallet driving your EVM app), fee/gas sizing, external-authority PDA + ATA derivation, and CPI calldata encoding for building on Rome (EVM chains that run on Solana).",
"type": "module",
"main": "./dist/index.js",
Expand Down
65 changes: 64 additions & 1 deletion src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,10 +392,20 @@ export interface SolanaLaneDeps {
*/
export async function submitRomeTxSolanaLane(
deps: SolanaLaneDeps,
call: EvmCall & { nonce?: bigint; fee?: FeeFields; extraAccounts?: AccountMeta[] },
call: EvmCall & { nonce?: bigint; fee?: FeeFields; extraAccounts?: AccountMeta[]; autoProvision?: boolean },
): Promise<{ signature: string; from: Hex }> {
const fetchImpl = deps.fetchImpl ?? fetch;
const from = syntheticAddress(deps.payer);
// First use of a brand-new synthetic: create its external-auth PDA. It doesn't
// exist until `create_pda` runs, and value-moving calls (the sweep's
// `transfer_spl`, ERC20SPL `transferFrom`) are signed by that PDA — so they
// revert until it's provisioned. Opt out with `autoProvision: false` to run
// your own one-time Activate step (see `provisionSynthetic`).
const createCall = buildCreatePdaCall(from);
const isCreatePdaCall = call.to === createCall.to && call.data === createCall.data;
if (call.autoProvision !== false && !isCreatePdaCall && !(await isSyntheticProvisioned(deps.connection, deps.programId, from))) {
await submitRomeTxSolanaLane(deps, { ...createCall, autoProvision: false });
}
const nonce =
call.nonce ?? BigInt(await jsonRpc<Hex>(deps.proxyUrl, "eth_getTransactionCount", [from, "pending"], fetchImpl));
const fee =
Expand Down Expand Up @@ -602,3 +612,56 @@ export function buildSweepLeg(params: {
];
return { ensureWalletAtaIx, helperTo: HELPER_PROGRAM, calldata, extraAccounts };
}

// ------------------------------------------- fresh-synthetic provisioning ---
// A Solana-native user's synthetic external-auth PDA is NOT lazy: it must be
// created once (`create_pda`) before any value-moving call, because the sweep's
// `transfer_spl` and ERC20SPL `transferFrom` are signed by that PDA.
// `submitRomeTxSolanaLane` auto-provisions on first use; these are the explicit
// primitives (e.g. for an app "Activate" step).

const HELPER_CREATE_PDA_ABI = [
{ type: "function", name: "create_pda", stateMutability: "nonpayable", inputs: [{ name: "user", type: "address" }], outputs: [] },
] as const;

/**
* The one-time provisioning call — `create_pda(synthetic)` on the HelperProgram
* (`0xFF…09`). Submit once (via {@link submitRomeTxSolanaLane}) before the
* synthetic's first value-moving write; gate on {@link isSyntheticProvisioned}.
*/
export function buildCreatePdaCall(synthetic: Hex): EvmCall {
return {
to: HELPER_PROGRAM,
data: encodeFunctionData({ abi: HELPER_CREATE_PDA_ABI, functionName: "create_pda", args: [synthetic] }),
};
}

/**
* Whether the synthetic's external-auth PDA already exists on-chain (i.e. it's
* been provisioned). One Solana-RPC account read.
*/
export async function isSyntheticProvisioned(
connection: Connection,
programId: PublicKey | string,
synthetic: Hex,
): Promise<boolean> {
return (await connection.getAccountInfo(deriveAuthorityPda(synthetic, pk(programId)))) !== null;
}

/**
* Provision a fresh synthetic if needed: create its external-auth PDA via
* `create_pda`. Returns the signature if it created one, else
* `{ alreadyProvisioned: true }`. The explicit form of what
* {@link submitRomeTxSolanaLane}'s default `autoProvision` does — call it once
* for an app "Activate" step, then submit writes with `autoProvision: false`.
*/
export async function provisionSynthetic(
deps: SolanaLaneDeps,
): Promise<{ signature?: string; alreadyProvisioned: boolean }> {
const synthetic = syntheticAddress(deps.payer);
if (await isSyntheticProvisioned(deps.connection, deps.programId, synthetic)) {
return { alreadyProvisioned: true };
}
const { signature } = await submitRomeTxSolanaLane(deps, { ...buildCreatePdaCall(synthetic), autoProvision: false });
return { signature, alreadyProvisioned: false };
}
47 changes: 45 additions & 2 deletions test/solana.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { PublicKey } from "@solana/web3.js";
import { PublicKey, type Connection } from "@solana/web3.js";
import { ComputeBudgetProgram } from "@solana/web3.js";
import { keccak256, parseTransaction, hexToBytes, type Hex } from "viem";
import { keccak256, parseTransaction, hexToBytes, decodeFunctionData, type Hex } from "viem";
import {
syntheticAddress,
buildUnsignedEip1559Rlp,
Expand All @@ -16,7 +16,12 @@ import {
ownerInfoPda,
buildFundLeg,
buildSweepLeg,
buildCreatePdaCall,
isSyntheticProvisioned,
provisionSynthetic,
HELPER_PROGRAM,
} from "../src/solana.js";
import { SELECTORS } from "../src/selectors.js";

// A deterministic 32-byte Solana pubkey for tests (not the zero key).
const PUBKEY_BYTES = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
Expand Down Expand Up @@ -197,3 +202,41 @@ describe("sweep leg (value-out)", () => {
expect(s.ensureWalletAtaIx.programId.toBase58()).toBe("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); // ATA program
});
});

describe("fresh-synthetic provisioning (create_pda)", () => {
it("buildCreatePdaCall targets the Helper precompile with create_pda(synthetic)", () => {
const synth = syntheticAddress(PUBKEY);
const call = buildCreatePdaCall(synth);
expect(call.to).toBe(HELPER_PROGRAM);
expect(call.data.startsWith(SELECTORS["create_pda(address)"])).toBe(true);
const decoded = decodeFunctionData({
abi: [{ type: "function", name: "create_pda", stateMutability: "nonpayable", inputs: [{ name: "user", type: "address" }], outputs: [] }],
data: call.data,
});
expect((decoded.args[0] as string).toLowerCase()).toBe(synth.toLowerCase());
});

it("isSyntheticProvisioned reflects whether the external-auth PDA exists on-chain", async () => {
const synth = syntheticAddress(PUBKEY);
const present = { getAccountInfo: async () => ({ data: new Uint8Array(1) }) } as unknown as Connection;
const absent = { getAccountInfo: async () => null } as unknown as Connection;
expect(await isSyntheticProvisioned(present, PROGRAM, synth)).toBe(true);
expect(await isSyntheticProvisioned(absent, PROGRAM, synth)).toBe(false);
});

it("provisionSynthetic short-circuits when already provisioned (submits no tx)", async () => {
const signTransaction = vi.fn();
const deps = {
connection: { getAccountInfo: async () => ({ data: new Uint8Array(1) }) },
proxyUrl: "https://proxy.example",
programId: PROGRAM,
chainId: CHAIN_ID,
payer: PUBKEY,
signTransaction,
} as unknown as Parameters<typeof provisionSynthetic>[0];
const res = await provisionSynthetic(deps);
expect(res.alreadyProvisioned).toBe(true);
expect(res.signature).toBeUndefined();
expect(signTransaction).not.toHaveBeenCalled();
});
});
Loading