commit 7826e182a8903f499b82e1f994a7027869333249
parent cef5dbc0cffb81c337d7e15a68f8ce240885f011
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 00:50:30 +0200
db: cascade the remaining parent deletes, declare them as foreign keys
Five more relationships now delete their children with the parent on both
backends: ON DELETE CASCADE in sqlite, done by hand in the IndexedDB DAL.
A conformance case per relationship pins the behaviour.
Diffstat:
4 files changed, 268 insertions(+), 15 deletions(-)
diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts
@@ -340,7 +340,11 @@ CREATE TABLE IF NOT EXISTS denominations (
exchange_master_pub BLOB NOT NULL,
currency TEXT NOT NULL,
value TEXT NOT NULL,
- denomination_family_serial INTEGER,
+ -- Nullable: a denomination may be stored before its family is known.
+ denomination_family_serial INTEGER
+ REFERENCES denomination_families(denomination_family_serial)
+ ON DELETE CASCADE
+ DEFERRABLE INITIALLY DEFERRED,
stamp_start INTEGER NOT NULL,
stamp_expire_withdraw INTEGER NOT NULL,
stamp_expire_deposit INTEGER NOT NULL,
@@ -472,7 +476,10 @@ CREATE INDEX IF NOT EXISTS slates_by_purchase_choice_output_repeat
ON slates (purchase_id, choice_index, output_index, repeat_index);
CREATE TABLE IF NOT EXISTS refresh_sessions (
- refresh_group_id TEXT NOT NULL,
+ refresh_group_id TEXT NOT NULL
+ REFERENCES refresh_groups(refresh_group_id)
+ ON DELETE CASCADE
+ DEFERRABLE INITIALLY DEFERRED,
coin_index INTEGER NOT NULL,
session_public_seed BLOB,
amount_refresh_output TEXT NOT NULL,
@@ -1023,7 +1030,10 @@ CREATE INDEX IF NOT EXISTS withdrawal_groups_by_taler_withdraw_uri
CREATE TABLE IF NOT EXISTS planchets (
coin_pub BLOB PRIMARY KEY,
coin_priv BLOB NOT NULL,
- withdrawal_group_id TEXT NOT NULL,
+ withdrawal_group_id TEXT NOT NULL
+ REFERENCES withdrawal_groups(withdrawal_group_id)
+ ON DELETE CASCADE
+ DEFERRABLE INITIALLY DEFERRED,
coin_idx INTEGER NOT NULL,
planchet_status INTEGER NOT NULL,
-- JSON: TalerErrorDetail
@@ -1095,14 +1105,20 @@ CREATE INDEX IF NOT EXISTS coin_availability_by_exchange_age_fresh
ON coin_availability (exchange_base_url, max_age, fresh_coin_count);
CREATE TABLE IF NOT EXISTS coin_history (
- coin_pub BLOB PRIMARY KEY,
+ coin_pub BLOB PRIMARY KEY
+ REFERENCES coins(coin_pub)
+ ON DELETE CASCADE
+ DEFERRABLE INITIALLY DEFERRED,
-- JSON: WalletCoinHistoryItem[]
history TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS refund_groups (
refund_group_id TEXT PRIMARY KEY,
- proposal_id TEXT NOT NULL,
+ proposal_id TEXT NOT NULL
+ REFERENCES purchases(proposal_id)
+ ON DELETE CASCADE
+ DEFERRABLE INITIALLY DEFERRED,
status INTEGER NOT NULL,
timestamp_created INTEGER NOT NULL,
amount_raw TEXT NOT NULL,
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -316,6 +316,46 @@ function makeFamilyParams(
return p;
}
+/**
+ * Create the parent rows a child record needs before it can be stored.
+ *
+ * The sqlite schema declares foreign keys for these relationships and the
+ * IndexedDB DAL cascades to match, so a fixture that invents a parent key
+ * without the parent is describing a state the wallet never produces. These
+ * helpers keep the cases realistic without repeating the setup in each one.
+ */
+async function seedDenomFamily(
+ tx: WalletDbTransaction,
+ exchangeBaseUrl: string,
+ serial: number,
+): Promise<number> {
+ return await tx.upsertDenominationFamily({
+ denominationFamilySerial: serial,
+ familyParams: makeFamilyParams(exchangeBaseUrl, `TESTKUDOS:${serial}`),
+ });
+}
+
+async function seedWithdrawalGroup(
+ tx: WalletDbTransaction,
+ withdrawalGroupId: string,
+): Promise<void> {
+ await tx.upsertWithdrawalGroup(makeWithdrawalGroup(withdrawalGroupId));
+}
+
+async function seedRefreshGroup(
+ tx: WalletDbTransaction,
+ refreshGroupId: string,
+): Promise<void> {
+ await tx.upsertRefreshGroup(makeRefreshGroup(refreshGroupId));
+}
+
+async function seedPurchase(
+ tx: WalletDbTransaction,
+ proposalId: string,
+): Promise<void> {
+ await tx.upsertPurchase(makePurchase(proposalId));
+}
+
function makeBankInfo(talerWithdrawUri: string): ReserveBankInfo {
const info: ReserveBankInfo = {
talerWithdrawUri,
@@ -730,6 +770,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "denomination: compound primary key (exchange, denomPubHash)",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam1/", 1);
await tx.upsertDenomination(makeDenomination("https://e1/", "dph-a"));
await tx.upsertDenomination(makeDenomination("https://e2/", "dph-a"));
});
@@ -752,6 +793,7 @@ export const conformanceCases: ConformanceCase[] = [
// in state "done" instead of "failed". It passed tsc and every unit test.
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedPurchase(tx, "prop-1");
// Items are written before their group here, deliberately: that is
// the order pay-merchant.ts uses, and a backend must tolerate it
// within a transaction.
@@ -780,6 +822,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "refund item lookup by (coinPub, rtxid)",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedPurchase(tx, "prop-1");
await tx.upsertRefundItem(makeRefundItem("grp-3", "coin-9", 42));
await tx.upsertRefundGroup(makeRefundGroup("grp-3"));
});
@@ -834,6 +877,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "findDenominationByFamilyFromExpiry returns the first match",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam77/", 77);
// Same family, ascending expiry. The first two are not offered, so
// the predicate must skip them.
await tx.upsertDenomination(
@@ -876,6 +920,7 @@ export const conformanceCases: ConformanceCase[] = [
// The predicate is the only place we can observe how far the scan ran.
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam88/", 88);
for (let i = 0; i < 20; i++) {
await tx.upsertDenomination(
makeDenomination("https://e/", `s-${i}`, {
@@ -912,6 +957,8 @@ export const conformanceCases: ConformanceCase[] = [
name: "findDenominationByFamilyFromExpiry respects the family boundary",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam101/", 101);
+ await seedDenomFamily(tx, "https://fam102/", 102);
await tx.upsertDenomination(
makeDenomination("https://e/", "f1-a", {
familySerial: 101,
@@ -941,6 +988,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "findDenominationByFamilyFromExpiry honours the expiry lower bound",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam111/", 111);
await tx.upsertDenomination(
makeDenomination("https://e/", "old", {
familySerial: 111,
@@ -1066,6 +1114,7 @@ export const conformanceCases: ConformanceCase[] = [
async run(t, runner) {
const before = await runner.runReadWriteTx((tx) => tx.getRecordCounts());
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam1/", 1);
await tx.upsertDenomination(makeDenomination("https://cnt/", "c-1"));
await tx.upsertDenomination(makeDenomination("https://cnt/", "c-2"));
});
@@ -1082,6 +1131,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "upsert overwrites an existing row rather than duplicating it",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam1/", 1);
await tx.upsertDenomination(
makeDenomination("https://e-up/", "dup", {
stampExpireWithdraw: 111,
@@ -1213,6 +1263,8 @@ export const conformanceCases: ConformanceCase[] = [
name: "refund group: get by id and list by proposal",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedPurchase(tx, "prop-x");
+ await seedPurchase(tx, "prop-y");
await tx.upsertRefundGroup(makeRefundGroup("rg-a", "prop-x"));
await tx.upsertRefundGroup(makeRefundGroup("rg-b", "prop-x"));
await tx.upsertRefundGroup(makeRefundGroup("rg-c", "prop-y"));
@@ -1240,6 +1292,128 @@ export const conformanceCases: ConformanceCase[] = [
},
{
+ name: "refresh group: delete cascades to its sessions",
+ async run(t, runner) {
+ await runner.runReadWriteTx(async (tx) => {
+ await seedRefreshGroup(tx, "rg-casc-a");
+ await seedRefreshGroup(tx, "rg-casc-b");
+ await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 0));
+ await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 1));
+ await tx.upsertRefreshSession(makeRefreshSession("rg-casc-b", 0));
+ });
+ await runner.runReadWriteTx((tx) => tx.deleteRefreshGroup("rg-casc-a"));
+ t.equal(
+ (
+ await runner.runReadWriteTx((tx) =>
+ tx.getRefreshSessionsByGroup("rg-casc-a"),
+ )
+ ).length,
+ 0,
+ );
+ t.equal(
+ (
+ await runner.runReadWriteTx((tx) =>
+ tx.getRefreshSessionsByGroup("rg-casc-b"),
+ )
+ ).length,
+ 1,
+ "sessions of another group must survive",
+ );
+ },
+ },
+
+ {
+ name: "withdrawal group: delete cascades to its planchets",
+ async run(t, runner) {
+ await runner.runReadWriteTx(async (tx) => {
+ await seedWithdrawalGroup(tx, "wg-casc-a");
+ await seedWithdrawalGroup(tx, "wg-casc-b");
+ await tx.upsertPlanchet(makePlanchet("pl-ca-0", "wg-casc-a", 0));
+ await tx.upsertPlanchet(makePlanchet("pl-ca-1", "wg-casc-a", 1));
+ await tx.upsertPlanchet(makePlanchet("pl-cb-0", "wg-casc-b", 0));
+ });
+ await runner.runReadWriteTx((tx) =>
+ tx.deleteWithdrawalGroup("wg-casc-a"),
+ );
+ t.equal(
+ (
+ await runner.runReadWriteTx((tx) =>
+ tx.getPlanchetsByGroup("wg-casc-a"),
+ )
+ ).length,
+ 0,
+ );
+ t.equal(
+ (
+ await runner.runReadWriteTx((tx) =>
+ tx.getPlanchetsByGroup("wg-casc-b"),
+ )
+ ).length,
+ 1,
+ "planchets of another group must survive",
+ );
+ },
+ },
+
+ {
+ // Two levels: the purchase owns the refund groups, which own the items.
+ name: "purchase: delete cascades through refund groups to their items",
+ async run(t, runner) {
+ await runner.runReadWriteTx(async (tx) => {
+ await seedPurchase(tx, "prop-casc");
+ await tx.upsertRefundGroup(makeRefundGroup("rg-pc", "prop-casc"));
+ await tx.upsertRefundItem(makeRefundItem("rg-pc", "coin-pc", 301));
+ });
+ await runner.runReadWriteTx((tx) => tx.deletePurchase("prop-casc"));
+ t.equal(
+ await runner.runReadWriteTx((tx) => tx.getRefundGroup("rg-pc")),
+ undefined,
+ "refund groups of the deleted purchase must be gone",
+ );
+ t.equal(
+ (await runner.runReadWriteTx((tx) => tx.getRefundItemsByGroup("rg-pc")))
+ .length,
+ 0,
+ "and their items with them",
+ );
+ },
+ },
+
+ {
+ name: "denomination family: delete cascades to its denominations",
+ async run(t, runner) {
+ await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam-a/", 41);
+ await seedDenomFamily(tx, "https://fam-b/", 42);
+ await tx.upsertDenomination(
+ makeDenomination("https://fam-a/", "dfa-1", { familySerial: 41 }),
+ );
+ await tx.upsertDenomination(
+ makeDenomination("https://fam-b/", "dfb-1", { familySerial: 42 }),
+ );
+ });
+ await runner.runReadWriteTx((tx) => tx.deleteDenominationFamily(41));
+ t.equal(
+ (
+ await runner.runReadWriteTx((tx) =>
+ tx.getDenominationsByExchange("https://fam-a/"),
+ )
+ ).length,
+ 0,
+ );
+ t.equal(
+ (
+ await runner.runReadWriteTx((tx) =>
+ tx.getDenominationsByExchange("https://fam-b/"),
+ )
+ ).length,
+ 1,
+ "denominations of another family must survive",
+ );
+ },
+ },
+
+ {
// Parent-delete behaviour has to be identical on both backends. sqlite
// declares ON DELETE CASCADE, IndexedDB has no constraints and must do it
// by hand; without a case here the two silently disagree, and the sqlite
@@ -1247,6 +1421,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "refund group: delete cascades to its items",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedPurchase(tx, "prop-1");
await tx.upsertRefundGroup(makeRefundGroup("rg-casc"));
await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c1", 201));
await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c2", 202));
@@ -1300,6 +1475,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "refund item: delete removes only the targeted item",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedPurchase(tx, "prop-1");
await tx.upsertRefundGroup(makeRefundGroup("rg-del"));
await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d1", 101));
await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d2", 102));
@@ -1323,6 +1499,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "denomination: list by verification status",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam1/", 1);
const a = makeDenomination("https://e1/", "vs-a");
a.verificationStatus = DenominationVerificationStatus.Unverified;
const b = makeDenomination("https://e1/", "vs-b");
@@ -1485,7 +1662,7 @@ export const conformanceCases: ConformanceCase[] = [
},
{
- name: "coin: delete removes the coin and its history independently",
+ name: "coin: delete cascades to its history",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
await tx.upsertCoin(makeCoin("cd-1"));
@@ -1493,20 +1670,38 @@ export const conformanceCases: ConformanceCase[] = [
coinPub: ck("cd-1"),
history: [{ type: "withdraw", transactionId: txnId("txn:cd-1") }],
});
+ await tx.upsertCoin(makeCoin("cd-2"));
+ await tx.upsertCoinHistory({
+ coinPub: ck("cd-2"),
+ history: [{ type: "withdraw", transactionId: txnId("txn:cd-2") }],
+ });
});
await runner.runReadWriteTx((tx) => tx.deleteCoin(ck("cd-1")));
t.equal(
await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-1"))),
undefined,
);
- const hist = await runner.runReadWriteTx((tx) =>
- tx.getCoinHistory(ck("cd-1")),
- );
- t.ok(hist, "deleteCoin must not delete the history record");
- await runner.runReadWriteTx((tx) => tx.deleteCoinHistory(ck("cd-1")));
t.equal(
await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-1"))),
undefined,
+ "deleting a coin must delete its history: every reader looks the" +
+ " history up for a coin it already holds, so a history row without" +
+ " its coin is unreachable",
+ );
+ t.ok(
+ await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))),
+ "the history of another coin must survive",
+ );
+ // deleteCoinHistory still exists on its own, for callers that want to
+ // drop the history while keeping the coin.
+ await runner.runReadWriteTx((tx) => tx.deleteCoinHistory(ck("cd-2")));
+ t.equal(
+ await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))),
+ undefined,
+ );
+ t.ok(
+ await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-2"))),
+ "deleteCoinHistory must not delete the coin",
);
},
},
@@ -1527,9 +1722,12 @@ export const conformanceCases: ConformanceCase[] = [
amount: amt("TESTKUDOS:0.25"),
},
];
- await runner.runReadWriteTx((tx) =>
- tx.upsertCoinHistory({ coinPub: ck("ch-1"), history }),
- );
+ await runner.runReadWriteTx(async (tx) => {
+ // The history belongs to a coin, and deleting that coin cascades to
+ // it, so the coin has to exist first.
+ await tx.upsertCoin(makeCoin("ch-1"));
+ await tx.upsertCoinHistory({ coinPub: ck("ch-1"), history });
+ });
const got = await runner.runReadWriteTx((tx) =>
tx.getCoinHistory(ck("ch-1")),
);
@@ -1991,6 +2189,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "denominations: listed by exchange",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedDenomFamily(tx, "https://fam1/", 1);
await tx.upsertDenomination(makeDenomination("https://dl1/", "d-1"));
await tx.upsertDenomination(makeDenomination("https://dl1/", "d-2"));
await tx.upsertDenomination(makeDenomination("https://dl2/", "d-3"));
@@ -2201,6 +2400,7 @@ export const conformanceCases: ConformanceCase[] = [
name: "planchet: round trips and is addressable by group and index",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedWithdrawalGroup(tx, "wg-p");
await tx.upsertPlanchet(makePlanchet("pl-1", "wg-p", 0));
await tx.upsertPlanchet(makePlanchet("pl-2", "wg-p", 1));
});
@@ -2220,7 +2420,10 @@ export const conformanceCases: ConformanceCase[] = [
async run(t, runner) {
const pl = makePlanchet("pl-err", "wg-e", 0);
pl.lastError = undefined;
- await runner.runReadWriteTx((tx) => tx.upsertPlanchet(pl));
+ await runner.runReadWriteTx(async (tx) => {
+ await seedWithdrawalGroup(tx, "wg-e");
+ await tx.upsertPlanchet(pl);
+ });
const got = await runner.runReadWriteTx((tx) =>
tx.getPlanchet(ck("pl-err")),
);
@@ -2236,6 +2439,8 @@ export const conformanceCases: ConformanceCase[] = [
name: "planchet: list, count and bulk delete by group",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedWithdrawalGroup(tx, "wg-bulk");
+ await seedWithdrawalGroup(tx, "wg-keep");
for (let i = 0; i < 4; i++) {
await tx.upsertPlanchet(makePlanchet(`pl-g${i}`, "wg-bulk", i));
}
@@ -2683,6 +2888,8 @@ export const conformanceCases: ConformanceCase[] = [
name: "refresh session: keyed by (group, coin index)",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
+ await seedRefreshGroup(tx, "rs-g");
+ await seedRefreshGroup(tx, "rs-other");
await tx.upsertRefreshSession(makeRefreshSession("rs-g", 0));
await tx.upsertRefreshSession(makeRefreshSession("rs-g", 1));
await tx.upsertRefreshSession(makeRefreshSession("rs-other", 0));
diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts
@@ -529,6 +529,10 @@ export class IdbWalletTransaction implements WalletDbTransaction {
async deleteCoin(coinPub: string): Promise<void> {
const tx = this.tx;
+ // Cascade to the history, which describes this coin and nothing else.
+ // Every reader looks it up for a coin it already holds, so a history row
+ // without its coin is unreachable. Matches the sqlite constraint.
+ await tx.coinHistory.delete(coinPub);
await tx.coins.delete(coinPub);
}
@@ -649,6 +653,19 @@ export class IdbWalletTransaction implements WalletDbTransaction {
denominationFamilySerial: number,
): Promise<void> {
const tx = this.tx;
+ // Cascade to the denominations of that family. There is no accessor for
+ // "denominations by family" on its own, so this walks the index whose
+ // first component is the family serial.
+ const all =
+ await tx.denominations.indexes.byDenominationFamilySerialAndStampExpireWithdraw
+ .iter()
+ .toArray();
+ const doomed = all.filter(
+ (d) => d.denominationFamilySerial === denominationFamilySerial,
+ );
+ for (const d of doomed) {
+ await tx.denominations.delete([d.exchangeBaseUrl, d.denomPubHash]);
+ }
await tx.denominationFamilies.delete(denominationFamilySerial);
}
@@ -803,6 +820,11 @@ export class IdbWalletTransaction implements WalletDbTransaction {
async deletePurchase(proposalId: string): Promise<void> {
const tx = this.tx;
+ // Cascade to the refund groups, and through deleteRefundGroup to their
+ // items -- two levels, matching what the sqlite constraints do.
+ for (const rg of await this.getRefundGroupsByProposal(proposalId)) {
+ await this.deleteRefundGroup(rg.refundGroupId);
+ }
await tx.purchases.delete(proposalId);
}
@@ -1024,6 +1046,8 @@ export class IdbWalletTransaction implements WalletDbTransaction {
async deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void> {
const tx = this.tx;
+ // Cascade to the planchets, which exist only as part of the group.
+ await this.deletePlanchetsByGroup(withdrawalGroupId);
await tx.withdrawalGroups.delete(withdrawalGroupId);
}
@@ -1108,6 +1132,10 @@ export class IdbWalletTransaction implements WalletDbTransaction {
async deleteRefreshGroup(refreshGroupId: string): Promise<void> {
const tx = this.tx;
+ // Cascade to the sessions, which exist only as part of the group.
+ for (const sess of await this.getRefreshSessionsByGroup(refreshGroupId)) {
+ await tx.refreshSessions.delete([refreshGroupId, sess.coinIndex]);
+ }
await tx.refreshGroups.delete(refreshGroupId);
}
diff --git a/packages/taler-wallet-core/src/dbtx-shared.ts b/packages/taler-wallet-core/src/dbtx-shared.ts
@@ -123,6 +123,8 @@ export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([
"deleteExchangeDetails",
"upsertDenomination",
"deleteDenomination",
+ // Cascades to denominations, so it invalidates the same caches.
+ "deleteDenominationFamily",
"addGlobalCurrencyExchange",
"deleteGlobalCurrencyExchange",
"addGlobalCurrencyAuditor",