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
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
}
},
"dependencies": {
"ox": "^0.14.8"
"ox": "^0.14.12"
},
"devDependencies": {
"@types/node": "^22.10.0",
Expand Down
2 changes: 1 addition & 1 deletion sdk/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 43 additions & 9 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,55 @@ export function toRoleDataSuffix(roles: RoleCodes): AttributionTagSuffix {
);
}

const appCode =
roles.app === undefined ? undefined : validateCode(roles.app, "app");
const walletCode =
roles.wallet === undefined
? undefined
: validateCode(roles.wallet, "wallet");
const serviceCodes =
service === undefined || service.length === 0
? undefined
: service.map((c) => validateCode(c, "service"));

// Build with only the keys that are present — ox's getSchemaId uses
// `key in attribution` checks, so `{ appCode: undefined }` would still
// select Schema 2 but is sloppier to reason about downstream.
const attribution: Parameters<typeof Attribution.toDataSuffix>[0] = {
id: 2,
...(roles.app !== undefined && { appCode: validateCode(roles.app, "app") }),
...(roles.wallet !== undefined && {
walletCode: validateCode(roles.wallet, "wallet"),
}),
...(service !== undefined &&
service.length > 0 && {
serviceCodes: service.map((c) => validateCode(c, "service")),
}),
...(appCode !== undefined && { appCode }),
...(walletCode !== undefined && { walletCode }),
...(serviceCodes !== undefined && { serviceCodes }),
};
return Attribution.toDataSuffix(attribution);
const suffix = Attribution.toDataSuffix(attribution);

// ox below 0.14.12 has no notion of `serviceCodes`: its getSchemaId
// ignores the key (and our explicit `id`), so a service-only tag falls
// through to the Schema 0 encoder and comes out empty, and an app+service
// tag silently drops the `s` codes. Decoding what we just encoded turns
// both silent corruptions into a loud failure — cheap, since encoding
// happens once per transaction.
const decoded = fromDataSuffix(suffix);
const roundTrips =
decoded !== null &&
decoded.schemaId === 2 &&
decoded.app === appCode &&
decoded.wallet === walletCode &&
sameCodes(decoded.service, serviceCodes);
if (!roundTrips) {
throw new Error(
"toRoleDataSuffix: the encoded suffix did not round-trip — the resolved `ox` version does not support ERC-8021 Schema 2 service codes. @celo/attribution-tags requires ox >= 0.14.12.",
);
}
return suffix;
}

function sameCodes(
a: readonly string[] | undefined,
b: readonly string[] | undefined,
): boolean {
if (a === undefined || b === undefined) return a === b;
return a.length === b.length && a.every((code, i) => code === b[i]);
}

export interface DecodedSuffix {
Expand Down
61 changes: 61 additions & 0 deletions sdk/tests/ox-floor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";

// Regression guard for the `ox` floor. `serviceCodes` (the ERC-8021 Schema 2
// `s` key) only landed in ox 0.14.12: older versions' `getSchemaId` checks
// `appCode` / `walletCode` / `codeRegistry` only and ignores the explicit
// `id`, so a service-only tag falls through to the Schema 0 encoder (empty
// suffix) and an app+service tag silently drops the service codes.
//
// package.json pins `ox >= 0.14.12`, but a consumer can still force an older
// ox with an override or a stale transitive pin. This file mocks ox back to
// the pre-0.14.12 behaviour and asserts toRoleDataSuffix fails loudly instead
// of emitting a corrupt tag. Kept in its own file so the mock stays isolated.
vi.mock("ox/erc8021", async (importOriginal) => {
const original = await importOriginal<typeof import("ox/erc8021")>();
return {
...original,
Attribution: {
...original.Attribution,
toDataSuffix(attribution: Record<string, unknown>) {
const hasAppOrWallet =
"appCode" in attribution || "walletCode" in attribution;
// Pre-0.14.12: no app/wallet code means Schema 0, and Schema 0 reads
// `codes` — which a role attribution never has.
if (!hasAppOrWallet)
return original.Attribution.toDataSuffix({ codes: [] });
// Pre-0.14.12: Schema 2 is selected, but the CBOR builder never reads
// `serviceCodes`.
const { appCode, walletCode } = attribution as {
appCode?: string;
walletCode?: string;
};
return original.Attribution.toDataSuffix({
...(appCode !== undefined && { appCode }),
...(walletCode !== undefined && { walletCode }),
});
},
},
};
});

const { toRoleDataSuffix } = await import("../src/index.js");

describe("ox floor guard", () => {
it("throws instead of emitting an empty Schema 0 tag for a service-only tag", () => {
expect(() => toRoleDataSuffix({ service: "celo_agent" })).toThrow(
/ox >= 0\.14\.12/,
);
});

it("throws instead of silently dropping service codes", () => {
expect(() =>
toRoleDataSuffix({ app: "celo_x", service: ["celo_agent"] }),
).toThrow(/ox >= 0\.14\.12/);
});

it("still encodes app / wallet tags, which old ox handles correctly", () => {
expect(() =>
toRoleDataSuffix({ app: "celo_x", wallet: "celo_facil" }),
).not.toThrow();
});
});
25 changes: 25 additions & 0 deletions sdk/tests/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ describe("toRoleDataSuffix / fromDataSuffix round-trip", () => {
});
});

it("round-trips a service-only tag (no app or wallet code)", () => {
// The service role is the one ox only learned about in 0.14.12 — on an
// older ox this encodes as an empty Schema 0 suffix instead.
const suffix = toRoleDataSuffix({ service: "celo_agent" });
expect(suffix.endsWith(`02${ERC_8021_MARKER.slice(2)}`)).toBe(true);
expect(suffix).toBe(Attribution.toDataSuffix({ serviceCodes: ["celo_agent"] }));
expect(fromDataSuffix(suffix)).toEqual({
codes: ["celo_agent"],
schemaId: 2,
service: ["celo_agent"],
});
});

it("round-trips a wallet + service tag (no app code)", () => {
const parsed = fromDataSuffix(
toRoleDataSuffix({ wallet: "celo_facil", service: ["celo_agent"] }),
);
expect(parsed).toEqual({
codes: ["celo_facil", "celo_agent"],
schemaId: 2,
wallet: "celo_facil",
service: ["celo_agent"],
});
});

it("round-trips multiple service codes", () => {
const parsed = fromDataSuffix(
toRoleDataSuffix({ app: "myapp", service: ["svc_one", "svc_two"] }),
Expand Down
Loading