Skip to content
Open
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
24 changes: 24 additions & 0 deletions typescript/.changeset/brown-chairs-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@sovereign-sdk/types": minor
"@sovereign-sdk/web3": minor
---

Add support for rollup height-based uniqueness. A transaction with a uniqueness value of `{ height: N }` will be valid from height N for a short time (depending on rollup configuration), as long as a transaction with the same value and same hash has not already been submitted. To use this, override the standard rollup in one of two ways:
* Create the rollup with an override. This will configure all transactions submitted through it to use the height mechanism by default; this can be overridden on a case by case basis.
```typescript
import { createStandardRollup, heightUniquenessBuilderOverride } from "@sovereign-sdk/web3";
const rollup = await createStandardRollup(undefined, heightUniquenessBuilderOverride());
```
* Provide an explicit one-off override for the `uniqueness` when using `rollup.call()` or `rollup.prepareCall()`:
```typescript
import { createStandardRollup, heightUniqueness } from "@sovereign-sdk/web3";
const rollup = await createStandardRollup();

const secondResult = await rollup.call(myRuntimeCall, {
signer: mySigner,
overrides: {
uniqueness: await heightUniqueness(rollup),
/* other overrides... */
}
});
```
9 changes: 6 additions & 3 deletions typescript/packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ export type Generation = { generation: number };
/** Sequential counter-based uniqueness mechanism for ordered transactions */
export type Nonce = { nonce: number };

/** Union type for transaction uniqueness mechanisms - either nonce-based or generation-based */
export type Uniqueness = Nonce | Generation;
/** Rollup-height-based uniqueness mechanism using the current chain height window */
export type Height = { height: number };

/** Union type for transaction uniqueness mechanisms */
export type Uniqueness = Nonce | Generation | Height;

/**
* Base transaction structure before signing, containing the core transaction data.
Expand All @@ -31,7 +34,7 @@ export type Uniqueness = Nonce | Generation;
export type UnsignedTransaction<RuntimeCall> = {
/** The specific runtime call/method being invoked on the rollup */
runtime_call: RuntimeCall;
/** Uniqueness mechanism (nonce or generation) to prevent replay attacks */
/** Uniqueness mechanism (nonce, generation, or height) to prevent replay attacks */
uniqueness: Uniqueness;
/** Transaction execution details including fees and gas limits */
details: TxDetails;
Expand Down
158 changes: 158 additions & 0 deletions typescript/packages/web3/src/rollup/standard-rollup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { RollupSchema, Serializer } from "@sovereign-sdk/serializers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
StandardRollup,
createRollupHeightUniquenessOverride,
createStandardRollup,
fetchCurrentRollupHeight,
heightUniqueness,
heightUniquenessBuilderOverride,
standardTypeBuilder,
} from "./standard-rollup";

Expand Down Expand Up @@ -144,6 +148,94 @@ describe("standardTypeBuilder", () => {
});
});

describe("height-based uniqueness helpers", () => {
const mockRollup = {
context: {
defaultTxDetails: {
max_priority_fee_bips: 100,
max_fee: "1000",
chain_id: 1,
},
},
http: {
get: vi.fn(),
},
};

beforeEach(() => {
vi.clearAllMocks();
});

it("should fetch the current rollup height", async () => {
mockRollup.http.get.mockResolvedValue({ value: [42, 99] });

const height = await fetchCurrentRollupHeight(mockRollup as any);

expect(height).toBe(42);
expect(mockRollup.http.get).toHaveBeenCalledWith(
"/modules/chain-state/state/current-heights",
);
});

it("should build a height-based uniqueness object", async () => {
mockRollup.http.get.mockResolvedValue({ value: [11, 99] });

const uniqueness = await heightUniqueness(mockRollup as any);

expect(uniqueness).toEqual({ height: 11 });
});

it("should throw when the current-heights response is missing rollup height", async () => {
mockRollup.http.get.mockResolvedValue({ value: [] });

await expect(fetchCurrentRollupHeight(mockRollup as any)).rejects.toThrow(
"missing rollup height",
);
});

it("should create an unsigned tx override that uses rollup height uniqueness", async () => {
mockRollup.http.get.mockResolvedValue({ value: [123, 999] });
const override = createRollupHeightUniquenessOverride();

const result = await override({
runtimeCall: { foo: "bar" },
overrides: {},
rollup: mockRollup as any,
});

expect(result).toEqual({
runtime_call: { foo: "bar" },
uniqueness: { height: 123 },
details: {
max_priority_fee_bips: 100,
max_fee: "1000",
chain_id: 1,
},
});
});

it("should preserve explicit uniqueness overrides when using height override helper", async () => {
const override = createRollupHeightUniquenessOverride();

const result = await override({
runtimeCall: { foo: "bar" },
overrides: { uniqueness: { generation: 5 } },
rollup: mockRollup as any,
});

expect(result).toEqual({
runtime_call: { foo: "bar" },
uniqueness: { generation: 5 },
details: {
max_priority_fee_bips: 100,
max_fee: "1000",
chain_id: 1,
},
});
expect(mockRollup.http.get).not.toHaveBeenCalled();
});
});

const mockSerializer = {
serialize: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3])),
serializeRuntimeCall: vi.fn().mockReturnValue(new Uint8Array([4, 5, 6])),
Expand Down Expand Up @@ -214,6 +306,72 @@ describe("createStandardRollup", () => {
expect(typeof typeBuilder.transaction).toBe("function");
});

it("should support the heightUniquenessBuilderOverride helper", async () => {
const rollup = await createStandardRollup(
mockConfig,
heightUniquenessBuilderOverride(),
);
rollup.http.get = vi.fn().mockResolvedValue({ value: [88, 999] });

const typeBuilder = (rollup as any)._typeBuilder;
const unsignedTx = await typeBuilder.unsignedTransaction({
runtimeCall: { foo: "bar" },
overrides: {},
rollup,
});

expect(unsignedTx).toEqual({
runtime_call: { foo: "bar" },
uniqueness: { height: 88 },
details: {
max_priority_fee_bips: 100,
max_fee: "1000",
chain_id: 1,
gas_limit: null,
},
});
});

it("should support manual uniqueness composition in one-off call overrides", async () => {
const rollup = await createStandardRollup(mockConfig);
const signer = {
sign: vi.fn().mockResolvedValue(new Uint8Array([7, 8, 9])),
publicKey: vi.fn().mockResolvedValue(new Uint8Array([4, 5, 6])),
};

rollup.http.get = vi.fn().mockResolvedValue({ value: [66, 999] });
rollup.http.rollup.schema = vi.fn().mockResolvedValue({
schema: { chain_data: { chain_id: 1 } },
chain_hash: "0x01020304",
});

const tx = await rollup.prepareCall({ foo: "bar" } as any, {
signer: signer as any,
overrides: {
uniqueness: await heightUniqueness(rollup),
details: {
max_fee: "2222",
},
},
});

expect(tx).toEqual(
expect.objectContaining({
V0: expect.objectContaining({
pub_key: "040506",
signature: "070809",
runtime_call: { foo: "bar" },
uniqueness: { height: 66 },
details: expect.objectContaining({
max_priority_fee_bips: 100,
max_fee: "2222",
chain_id: 1,
}),
}),
}),
);
});

it("should be created using the default context", async () => {
mockConfig.client.rollup.constants = vi
.fn()
Expand Down
144 changes: 114 additions & 30 deletions typescript/packages/web3/src/rollup/standard-rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export type StandardRollupSpec<RuntimeCall> = {
Dedup: Dedup;
};

const CURRENT_HEIGHTS_ENDPOINT = "/modules/chain-state/state/current-heights";

type CurrentHeightsResponse = {
value: [number, number] | number[];
};

const useOrFetchUniqueness = async <S extends StandardRollupSpec<unknown>>({
overrides,
}: Omit<
Expand All @@ -44,40 +50,118 @@ const useOrFetchUniqueness = async <S extends StandardRollupSpec<unknown>>({
return { generation: Date.now() };
};

/**
* Fetches the current rollup height from the chain-state module.
*/
export async function fetchCurrentRollupHeight<
S extends StandardRollupSpec<unknown>,
>(rollup: Pick<Rollup<S, StandardRollupContext>, "http">): Promise<number> {
const { value } = await rollup.http.get<CurrentHeightsResponse>(
CURRENT_HEIGHTS_ENDPOINT,
);
const rollupHeight = value[0];

if (typeof rollupHeight !== "number") {
throw new Error(
`Unexpected response from ${CURRENT_HEIGHTS_ENDPOINT}: missing rollup height`,
);
}

return rollupHeight;
}

/**
* Builds a height-based uniqueness value from the current rollup height.
*/
export async function heightUniqueness<S extends StandardRollupSpec<unknown>>(
rollup: Pick<Rollup<S, StandardRollupContext>, "http">,
) {
const height = await fetchCurrentRollupHeight(rollup);
return { height };
}

/**
* Shared implementation of the default unsigned transaction builder.
*/
export async function buildStandardUnsignedTransaction<
S extends StandardRollupSpec<unknown>,
>(context: UnsignedTransactionContext<S, StandardRollupContext>) {
const { rollup, runtimeCall } = context;
const { uniqueness: _, ...overrides } = context.overrides;
const uniqueness = await useOrFetchUniqueness(context);
const details: TxDetails = {
...rollup.context.defaultTxDetails,
...overrides.details,
};

return {
runtime_call: runtimeCall,
uniqueness,
details,
} as S["UnsignedTransaction"];
}

/**
* Shared implementation of the default signed transaction builder.
*/
export async function buildStandardTransaction<
S extends StandardRollupSpec<unknown>,
>({
sender,
signature,
unsignedTx,
}: TransactionContext<S, StandardRollupContext>) {
return {
V0: {
pub_key: bytesToHex(sender),
signature: bytesToHex(signature),
...unsignedTx,
},
};
}

/**
* Creates an unsigned-transaction override for `createStandardRollup` that uses
* height-based replay protection by default.
*
* Any explicitly supplied per-call `overrides.uniqueness` still takes precedence.
*/
export function createRollupHeightUniquenessOverride<
S extends StandardRollupSpec<unknown>,
>(): TypeBuilder<S, StandardRollupContext>["unsignedTransaction"] {
return async (context) => {
const unsignedTx = await buildStandardUnsignedTransaction(context);

if (context.overrides?.uniqueness) {
return unsignedTx;
}

const uniqueness = await heightUniqueness(context.rollup);

return {
...unsignedTx,
uniqueness,
};
};
}

/**
* Builder-level helper for `createStandardRollup(..., typeBuilderOverrides)`.
*/
export function heightUniquenessBuilderOverride<
S extends StandardRollupSpec<unknown>,
>(): Partial<TypeBuilder<S, StandardRollupContext>> {
return {
unsignedTransaction: createRollupHeightUniquenessOverride<S>(),
};
}

export function standardTypeBuilder<
S extends StandardRollupSpec<unknown>,
>(): TypeBuilder<S, StandardRollupContext> {
return {
async unsignedTransaction(
context: UnsignedTransactionContext<S, StandardRollupContext>,
) {
const { rollup, runtimeCall } = context;
const { uniqueness: _, ...overrides } = context.overrides;
const uniqueness = await useOrFetchUniqueness(context);
const details: TxDetails = {
...rollup.context.defaultTxDetails,
...overrides.details,
};

return {
runtime_call: runtimeCall,
uniqueness,
details,
} as S["UnsignedTransaction"];
},
async transaction({
sender,
signature,
unsignedTx,
}: TransactionContext<S, StandardRollupContext>) {
return {
V0: {
pub_key: bytesToHex(sender),
signature: bytesToHex(signature),
...unsignedTx,
},
};
},
unsignedTransaction: buildStandardUnsignedTransaction,
transaction: buildStandardTransaction,
};
}

Expand Down