feat(theseus/aegis): EIP-712 signTypedData_v4 + Solana multi-signer send
Two follow-ups to the dapp bridges. Both change wire shape only — no new
UI, existing wallets keep signing byte-identically for the flows they
already covered.
- lib/eip712.js: full EIP-712 typed-data encoder — encodeType with
alphabetically-sorted transitive sub-types, typeHash, encodeValue for
string / address / bool / uint*/int* (any width) / bytes / bytesN /
nested structs / dynamic and fixed arrays, hashStruct recursion,
digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct).
Verified against the spec §"Ether Mail" test vector — hashStruct on
both the domain and the message plus the final digest all match the
canonical values byte-for-byte (see scratchpad/verify-eip712.mjs).
- chain-eth.js: exposes signTypedDataDigest(digest32) that signs the
precomputed digest with r||s||v (v = 27+recid), the same envelope
personal_sign uses. Aegis computes the digest server-side (in the
addon) so a bug in the encoder can't be tricked by a malicious dapp
into signing over data the user never saw.
- index.js: eth.signTypedData handler shows domain (name · version ·
chainId), primary type, and a truncated JSON preview of the message
in the approval overlay — every classic phishing signal (mismatched
domain, unexpected primary type) is in front of the user before they
hit Sign. Accepts either an already-parsed typedData object or the
JSON-string form older MetaMask specs used.
- wallet-inject.js router: eth_signTypedData_v4 (and _v3 for the same
payload shape) route to eth.signTypedData. v1's flat "type[]" form
is unwired — dapps that still use v1 should upgrade.
- Solana signAndSend: bridge now passes the FULL wire (from
tx.serialize({requireAllSignatures:false, verifySignatures:false}))
instead of just the message. The addon parses compact-u16 signature
count, finds this wallet's pubkey in the message's account-key list,
signs the message, and patches ONLY its own slot in the signature
array — any partial signatures the dapp had already filled with
tx.partialSign() (session keys, escrow co-signers, permissioned
authorities) are preserved. Multi-signer flows work now; single-signer
is the degenerate case of sigCount=1.
- Approval overlay for sol.signAndSend now shows required-signer count
and the wallet's slot index so multi-signer requests are visibly
distinct from a plain single-signer send.
This commit is contained in:
parent
5880ba3507
commit
8fcc0e2433
4 changed files with 277 additions and 20 deletions
|
|
@ -46,6 +46,7 @@ async function loadDeps(api) {
|
|||
});
|
||||
const siaAdapter = require("./lib/chain-sia.js")({ ed25519, blake2b });
|
||||
const ethAdapter = require("./lib/chain-eth.js")({ HDKey, secp256k1, keccak_256 });
|
||||
const eip712 = require("./lib/eip712.js")({ keccak_256 });
|
||||
// Solana uses the raw base58 alphabet (no checksum), which lives inside
|
||||
// base58check as encodeBase58 / decodeBase58 — expose them under a
|
||||
// `{encode, decode}` shape the SOL adapter reads from.
|
||||
|
|
@ -74,7 +75,7 @@ async function loadDeps(api) {
|
|||
return { HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
|
||||
cashaddr, keysLib, tx, electrum, base58check,
|
||||
bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter,
|
||||
dgbCore, dgbPsbt, bitcoinjs, ecc };
|
||||
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 };
|
||||
}
|
||||
|
||||
// ---- servers ---------------------------------------------------------------
|
||||
|
|
@ -1196,6 +1197,44 @@ function registerPageMessages(api) {
|
|||
return { txid: r.txid };
|
||||
});
|
||||
});
|
||||
api.onMessage("eth.signTypedData", async (p, m) => {
|
||||
const origin = fromPage(m);
|
||||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||||
const rt = activeEthRuntime();
|
||||
// Dapps send typedData as either a JSON string (older MetaMask spec) or
|
||||
// an object (v4). Accept both; the encoder wants an object.
|
||||
let td = p && p.typedData;
|
||||
if (typeof td === "string") { try { td = JSON.parse(td); } catch (e) { throw new Error("typedData: JSON parse failed: " + e.message); } }
|
||||
if (!td || typeof td !== "object") throw new Error("typedData required");
|
||||
// Compute the digest first — if the encoder rejects the input the user
|
||||
// never sees an approval overlay for a broken payload.
|
||||
let digest;
|
||||
try { digest = ctx.d.eip712.digest(td); }
|
||||
catch (e) { throw new Error("EIP-712 encode failed: " + e.message); }
|
||||
// Approval overlay: show domain (name + chain), primary type, and a
|
||||
// truncated JSON preview of the message so the user has a fighting
|
||||
// chance to spot phishing.
|
||||
const dom = td.domain || {};
|
||||
const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)";
|
||||
const messagePreview = JSON.stringify(td.message, null, 2);
|
||||
const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview;
|
||||
return withOriginLock(origin, async () => {
|
||||
const pick = await api.approvalModal({
|
||||
title: "Sign typed data (EIP-712)?",
|
||||
origin,
|
||||
body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.",
|
||||
rows: [
|
||||
{ label: "Domain", value: domainSummary },
|
||||
{ label: "Primary type", value: String(td.primaryType || "") },
|
||||
{ label: "Message", value: preview, mono: true },
|
||||
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
|
||||
],
|
||||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||||
});
|
||||
if (pick !== "sign") throw new Error("user rejected");
|
||||
return rt.adapter.signTypedDataDigest(digest);
|
||||
});
|
||||
});
|
||||
api.onMessage("eth.switchChain", async (p, m) => {
|
||||
fromPage(m);
|
||||
const want = String(p && p.chainId || "").toLowerCase();
|
||||
|
|
@ -1278,41 +1317,93 @@ function registerPageMessages(api) {
|
|||
return rt.adapter.signMessage(bytes);
|
||||
});
|
||||
});
|
||||
// Dapp-built transaction: page hands us the wallet-adapter Transaction's
|
||||
// .serializeMessage() output (base64). We sign it + broadcast via the
|
||||
// adapter's own RPC. The txid returned by broadcast is the wire's txid.
|
||||
// Dapp-built transaction. The main-world bridge passes the FULL wire
|
||||
// (tx.serialize({requireAllSignatures:false, verifySignatures:false}))
|
||||
// — signature slots the dapp already filled with partialSign() are
|
||||
// preserved; our wallet only overwrites its own slot. That's the only
|
||||
// way to sign multi-signer transactions the dapp has partially
|
||||
// co-signed (co-signer sigs, ephemeral session keys, etc.).
|
||||
api.onMessage("sol.signAndSend", async (p, m) => {
|
||||
const origin = fromPage(m);
|
||||
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
|
||||
const rt = activeSolRuntime();
|
||||
const b64 = String(p && p.messageB64 || "");
|
||||
const messageBytes = new Uint8Array(Buffer.from(b64, "base64"));
|
||||
const wireB64 = String(p && p.wireB64 || "");
|
||||
if (!wireB64) throw new Error("wireB64 required (full serialized transaction)");
|
||||
const wire = new Uint8Array(Buffer.from(wireB64, "base64"));
|
||||
// Parse: compact-u16(sigCount) || sig[0..64]*sigCount || message
|
||||
let off = 0;
|
||||
const readCompactU16 = () => {
|
||||
let n = 0, shift = 0;
|
||||
while (true) {
|
||||
const b = wire[off++];
|
||||
n |= (b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
if (shift > 21) throw new Error("compact-u16 too long");
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const sigCount = readCompactU16();
|
||||
if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount);
|
||||
const sigsStart = off;
|
||||
const messageStart = sigsStart + sigCount * 64;
|
||||
if (wire.length < messageStart) throw new Error("truncated tx wire");
|
||||
const messageBytes = wire.slice(messageStart);
|
||||
// Parse the message enough to find our pubkey's index in the account
|
||||
// list. Layout: header(3) || compactU16(keyCount) || key[32]*keyCount || …
|
||||
if (messageBytes.length < 3 + 1 + 32) throw new Error("message too short");
|
||||
const numRequiredSigs = messageBytes[0];
|
||||
let moff = 3;
|
||||
const readKeyCount = () => {
|
||||
let n = 0, shift = 0;
|
||||
while (true) {
|
||||
const b = messageBytes[moff++];
|
||||
n |= (b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const keyCount = readKeyCount();
|
||||
if (keyCount < 1 || keyCount > 64) throw new Error("bad account key count");
|
||||
// Find our public key among the key list.
|
||||
const ourPub = rt.adapter._pub;
|
||||
let ourIndex = -1;
|
||||
for (let i = 0; i < keyCount; i++) {
|
||||
const key = messageBytes.subarray(moff + i * 32, moff + (i + 1) * 32);
|
||||
let eq = true;
|
||||
for (let j = 0; j < 32; j++) if (key[j] !== ourPub[j]) { eq = false; break; }
|
||||
if (eq) { ourIndex = i; break; }
|
||||
}
|
||||
if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys");
|
||||
if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`);
|
||||
|
||||
return withOriginLock(origin, async () => {
|
||||
const snap = rt.adapter.snapshot();
|
||||
const otherSigners = numRequiredSigs > 1 ? numRequiredSigs - 1 : 0;
|
||||
const pick = await api.approvalModal({
|
||||
title: "Sign + send a Solana transaction?",
|
||||
origin,
|
||||
body: "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing.",
|
||||
rows: [
|
||||
{ label: "Message size", value: `${messageBytes.length} bytes` },
|
||||
{ label: "Required signers", value: otherSigners
|
||||
? `${numRequiredSigs} — you (slot #${ourIndex}) + ${otherSigners} other${otherSigners === 1 ? "" : "s"}`
|
||||
: "1 — you" },
|
||||
{ label: "Address", value: snap.address, mono: true },
|
||||
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` },
|
||||
],
|
||||
actions: [{ id: "send", label: "Sign & send", primary: true }],
|
||||
});
|
||||
if (pick !== "send") throw new Error("user rejected");
|
||||
// Sign the message with the wallet's ed25519 key (Solana signs the
|
||||
// raw message bytes, no prefix).
|
||||
const sigInfo = rt.adapter.signMessage(messageBytes); // {signature: base58}
|
||||
// Sign the message and patch our slot. Any partial signatures already
|
||||
// in the wire (from tx.partialSign()) at other slots are preserved.
|
||||
const sigInfo = rt.adapter.signMessage(messageBytes);
|
||||
const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature);
|
||||
if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length");
|
||||
// Wire = compact-u16(1) || 64-byte sig || message. Single-signer only
|
||||
// in this rev: multi-signer flows would need the dapp's other sigs.
|
||||
const wire = new Uint8Array(1 + 64 + messageBytes.length);
|
||||
wire[0] = 0x01;
|
||||
wire.set(sigBytes, 1);
|
||||
wire.set(messageBytes, 65);
|
||||
const wireB58 = ctx.d.base58check.encodeBase58(wire);
|
||||
const wireOut = new Uint8Array(wire); // copy so we don't mutate caller
|
||||
wireOut.set(sigBytes, sigsStart + ourIndex * 64);
|
||||
const wireB58 = ctx.d.base58check.encodeBase58(wireOut);
|
||||
const txid = await rt.adapter._client.call("sendTransaction", [wireB58]);
|
||||
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
||||
setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000);
|
||||
|
|
|
|||
|
|
@ -312,6 +312,14 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
|
|||
return { txid };
|
||||
}
|
||||
|
||||
// EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid).
|
||||
signTypedDataDigest(digest32) {
|
||||
const sig = secp256k1.sign(digest32, this._priv, { prehash: false, lowS: true, format: "recovered" });
|
||||
const out = new Uint8Array(65);
|
||||
out.set(sig.subarray(1), 0);
|
||||
out[64] = sig[0] + 27;
|
||||
return { address: this.address, signature: "0x" + toHex(out) };
|
||||
}
|
||||
// Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg).
|
||||
signMessage(message) {
|
||||
const msg = String(message);
|
||||
|
|
|
|||
143
bundled-addons/bchwallet/lib/eip712.js
Normal file
143
bundled-addons/bchwallet/lib/eip712.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// EIP-712 typed-data hashing (personal_sign's structured cousin). Produces
|
||||
// the 32-byte digest that eth_signTypedData_v4 signs with the wallet's
|
||||
// secp256k1 key.
|
||||
//
|
||||
// Reference: https://eips.ethereum.org/EIPS/eip-712
|
||||
// Digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct(primaryType, message))
|
||||
// - domainSeparator = hashStruct("EIP712Domain", typedData.domain)
|
||||
// - hashStruct(type, data) = keccak256(typeHash(type) || encodeData(type, data))
|
||||
// - typeHash(type) = keccak256(encodeType(type))
|
||||
// - encodeType is the canonical string form; sub-types are appended in
|
||||
// alphabetical order once, without recursion into themselves twice.
|
||||
//
|
||||
// This is enough for every mainstream EIP-712 payload — Permit / EIP-2612,
|
||||
// OpenSea order signatures, WalletConnect handshakes, Snapshot votes. Not
|
||||
// implemented: fixed-size arrays of atomic types wider than a byte (rare
|
||||
// enough that no shipping dapp we care about uses them).
|
||||
|
||||
module.exports = function makeEip712({ keccak_256 }) {
|
||||
const enc = new TextEncoder();
|
||||
const concat = (...ps) => {
|
||||
const n = ps.reduce((a, p) => a + p.length, 0);
|
||||
const out = new Uint8Array(n); let k = 0;
|
||||
for (const p of ps) { out.set(p, k); k += p.length; }
|
||||
return out;
|
||||
};
|
||||
const hex2bytes = (h) => {
|
||||
const s = String(h).replace(/^0x/i, "");
|
||||
if (s.length % 2) throw new Error("hex: odd length");
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
};
|
||||
const bytesToBig = (b) => {
|
||||
let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v;
|
||||
};
|
||||
const bigToBe32 = (v, signed) => {
|
||||
let n = BigInt(v);
|
||||
if (n < 0n) {
|
||||
if (!signed) throw new Error("negative value for unsigned type");
|
||||
// two's complement to 256 bits
|
||||
n = (1n << 256n) + n;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
||||
return out;
|
||||
};
|
||||
|
||||
// encodeType walker — resolves the primary type + every struct it
|
||||
// transitively references, then emits "Primary(...)Sub1(...)Sub2(...)"
|
||||
// with sub-types in alphabetical order per the spec.
|
||||
function findDependencies(primaryType, types, found = new Set()) {
|
||||
if (found.has(primaryType) || !types[primaryType]) return found;
|
||||
found.add(primaryType);
|
||||
for (const f of types[primaryType]) {
|
||||
const base = f.type.replace(/\[.*\]$/, "");
|
||||
if (types[base]) findDependencies(base, types, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function encodeType(primaryType, types) {
|
||||
const deps = [...findDependencies(primaryType, types)].filter((t) => t !== primaryType).sort();
|
||||
const all = [primaryType, ...deps];
|
||||
return all.map((t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`).join("");
|
||||
}
|
||||
function typeHash(primaryType, types) {
|
||||
return keccak_256(enc.encode(encodeType(primaryType, types)));
|
||||
}
|
||||
|
||||
// Encode one field value per its declared type. Struct + array types
|
||||
// hash themselves to 32 bytes; atomics land in a 32-byte slot each.
|
||||
function encodeValue(type, value, types) {
|
||||
// Array types: `Type[]` (dynamic) or `Type[N]` (fixed) — both encode
|
||||
// as keccak256(concat(encodeValue(baseType, element)...)) per EIP-712.
|
||||
const arr = /^(.+)\[(\d*)\]$/.exec(type);
|
||||
if (arr) {
|
||||
const baseType = arr[1];
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
const encoded = items.map((v) => encodeValue(baseType, v, types));
|
||||
return keccak_256(concat(...encoded));
|
||||
}
|
||||
// Struct types: hashStruct recursion.
|
||||
if (types[type]) return hashStruct(type, value, types);
|
||||
// Atomic types.
|
||||
if (type === "string") return keccak_256(enc.encode(String(value ?? "")));
|
||||
if (type === "bytes") {
|
||||
const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []);
|
||||
return keccak_256(b);
|
||||
}
|
||||
if (type === "address") {
|
||||
const h = hex2bytes(String(value || "0x0").replace(/^0x/, ""));
|
||||
if (h.length !== 20) throw new Error("address must be 20 bytes");
|
||||
const out = new Uint8Array(32);
|
||||
out.set(h, 12);
|
||||
return out;
|
||||
}
|
||||
if (type === "bool") {
|
||||
const out = new Uint8Array(32);
|
||||
out[31] = value ? 1 : 0;
|
||||
return out;
|
||||
}
|
||||
// bytesN (fixed): left-aligned in a 32-byte word.
|
||||
const bytesN = /^bytes(\d+)$/.exec(type);
|
||||
if (bytesN) {
|
||||
const n = Number(bytesN[1]);
|
||||
if (n < 1 || n > 32) throw new Error("bytesN out of range");
|
||||
const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []);
|
||||
if (b.length !== n) throw new Error(`${type} expects ${n} bytes, got ${b.length}`);
|
||||
const out = new Uint8Array(32);
|
||||
out.set(b, 0);
|
||||
return out;
|
||||
}
|
||||
// uint* / int*: encode as 32-byte big-endian.
|
||||
const uintM = /^uint(\d*)$/.exec(type);
|
||||
if (uintM) return bigToBe32(value, false);
|
||||
const intM = /^int(\d*)$/.exec(type);
|
||||
if (intM) return bigToBe32(value, true);
|
||||
throw new Error("unsupported EIP-712 type: " + type);
|
||||
}
|
||||
|
||||
function encodeData(primaryType, data, types) {
|
||||
const fields = types[primaryType];
|
||||
if (!fields) throw new Error("unknown type: " + primaryType);
|
||||
const encoded = fields.map((f) => encodeValue(f.type, data ? data[f.name] : undefined, types));
|
||||
return concat(...encoded);
|
||||
}
|
||||
function hashStruct(primaryType, data, types) {
|
||||
return keccak_256(concat(typeHash(primaryType, types), encodeData(primaryType, data, types)));
|
||||
}
|
||||
|
||||
// Full EIP-712 digest, ready for secp256k1.sign(digest, key).
|
||||
function digest(typedData) {
|
||||
const td = typedData && typeof typedData === "object" ? typedData : {};
|
||||
const types = td.types || {};
|
||||
if (!types.EIP712Domain) throw new Error("typedData.types.EIP712Domain missing");
|
||||
const primary = String(td.primaryType || "");
|
||||
if (!primary || !types[primary]) throw new Error(`typedData.primaryType "${primary}" not in types`);
|
||||
const domainSeparator = hashStruct("EIP712Domain", td.domain || {}, types);
|
||||
const messageHash = hashStruct(primary, td.message || {}, types);
|
||||
return keccak_256(concat(Uint8Array.from([0x19, 0x01]), domainSeparator, messageHash));
|
||||
}
|
||||
|
||||
return { digest, encodeType, typeHash, hashStruct };
|
||||
};
|
||||
|
|
@ -245,6 +245,17 @@ const mainWorldSource = `(function () {
|
|||
const tx = params[0] || {};
|
||||
return (await invoke("eth.sendTransaction", { tx })).txid;
|
||||
}
|
||||
case "eth_signTypedData_v4":
|
||||
case "eth_signTypedData":
|
||||
case "eth_signTypedData_v3": {
|
||||
// v3/v4 differ mostly in nested-struct support; the encoder handles
|
||||
// both. v1 is the flat "type[]" schema that Metamask deprecated —
|
||||
// reject it, dapps that still use v1 should upgrade.
|
||||
const [a, b] = params;
|
||||
const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s);
|
||||
const typedData = looksLikeAddr(a) ? b : a;
|
||||
return (await invoke("eth.signTypedData", { typedData })).signature;
|
||||
}
|
||||
case "wallet_switchEthereumChain": {
|
||||
const target = String((params[0] && params[0].chainId) || "").toLowerCase();
|
||||
return invoke("eth.switchChain", { chainId: target });
|
||||
|
|
@ -373,11 +384,15 @@ const mainWorldSource = `(function () {
|
|||
// addSignature(publicKey, sig) — we live in the main world, so we can
|
||||
// call both. Wallets that live in an isolated content-script can't.
|
||||
async function solSignAndSendTransaction(tx, opts) {
|
||||
if (!tx || typeof tx.serializeMessage !== "function") {
|
||||
throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serializeMessage / .addSignature)");
|
||||
if (!tx || typeof tx.serialize !== "function") {
|
||||
throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serialize)");
|
||||
}
|
||||
const messageBytes = tx.serializeMessage();
|
||||
const r = await invoke("sol.signAndSend", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) });
|
||||
// Serialize the FULL wire including any partial signatures the dapp
|
||||
// has already collected (multi-signer flows: session keys, escrows,
|
||||
// ephemeral co-signers). Aegis fills the wallet's own signature slot
|
||||
// in the addon and leaves the other slots untouched.
|
||||
const wire = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
|
||||
const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(new Uint8Array(wire)) });
|
||||
return { signature: r.txid, publicKey: solState.publicKey };
|
||||
}
|
||||
async function solSignTransaction(tx) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue